@openlivesync/server 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.
Files changed (82) hide show
  1. package/README.md +366 -0
  2. package/dist/auth/decode-token.d.ts +40 -0
  3. package/dist/auth/decode-token.d.ts.map +1 -0
  4. package/dist/auth/decode-token.js +93 -0
  5. package/dist/auth/decode-token.js.map +1 -0
  6. package/dist/auth/index.d.ts +6 -0
  7. package/dist/auth/index.d.ts.map +1 -0
  8. package/dist/auth/index.js +6 -0
  9. package/dist/auth/index.js.map +1 -0
  10. package/dist/auth/token-auth.d.ts +18 -0
  11. package/dist/auth/token-auth.d.ts.map +1 -0
  12. package/dist/auth/token-auth.js +45 -0
  13. package/dist/auth/token-auth.js.map +1 -0
  14. package/dist/connection.d.ts +36 -0
  15. package/dist/connection.d.ts.map +1 -0
  16. package/dist/connection.js +170 -0
  17. package/dist/connection.js.map +1 -0
  18. package/dist/index.d.ts +19 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +15 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/protocol.d.ts +135 -0
  23. package/dist/protocol.d.ts.map +1 -0
  24. package/dist/protocol.js +17 -0
  25. package/dist/protocol.js.map +1 -0
  26. package/dist/room-manager.d.ts +19 -0
  27. package/dist/room-manager.d.ts.map +1 -0
  28. package/dist/room-manager.js +34 -0
  29. package/dist/room-manager.js.map +1 -0
  30. package/dist/room.d.ts +41 -0
  31. package/dist/room.d.ts.map +1 -0
  32. package/dist/room.js +150 -0
  33. package/dist/room.js.map +1 -0
  34. package/dist/server.d.ts +46 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/server.js +105 -0
  37. package/dist/server.js.map +1 -0
  38. package/dist/storage/chat-storage.d.ts +19 -0
  39. package/dist/storage/chat-storage.d.ts.map +1 -0
  40. package/dist/storage/chat-storage.js +6 -0
  41. package/dist/storage/chat-storage.js.map +1 -0
  42. package/dist/storage/in-memory.d.ts +11 -0
  43. package/dist/storage/in-memory.d.ts.map +1 -0
  44. package/dist/storage/in-memory.js +35 -0
  45. package/dist/storage/in-memory.js.map +1 -0
  46. package/dist/storage/mysql.d.ts +19 -0
  47. package/dist/storage/mysql.d.ts.map +1 -0
  48. package/dist/storage/mysql.js +70 -0
  49. package/dist/storage/mysql.js.map +1 -0
  50. package/dist/storage/postgres.d.ts +21 -0
  51. package/dist/storage/postgres.d.ts.map +1 -0
  52. package/dist/storage/postgres.js +70 -0
  53. package/dist/storage/postgres.js.map +1 -0
  54. package/dist/storage/sqlite.d.ts +15 -0
  55. package/dist/storage/sqlite.d.ts.map +1 -0
  56. package/dist/storage/sqlite.js +71 -0
  57. package/dist/storage/sqlite.js.map +1 -0
  58. package/package.json +51 -0
  59. package/src/auth/decode-token.test.ts +119 -0
  60. package/src/auth/decode-token.ts +138 -0
  61. package/src/auth/index.ts +16 -0
  62. package/src/auth/token-auth.test.ts +95 -0
  63. package/src/auth/token-auth.ts +55 -0
  64. package/src/connection.test.ts +339 -0
  65. package/src/connection.ts +204 -0
  66. package/src/index.ts +80 -0
  67. package/src/protocol.test.ts +29 -0
  68. package/src/protocol.ts +137 -0
  69. package/src/room-manager.ts +45 -0
  70. package/src/room.test.ts +175 -0
  71. package/src/room.ts +207 -0
  72. package/src/server.test.ts +223 -0
  73. package/src/server.ts +153 -0
  74. package/src/storage/chat-storage.ts +23 -0
  75. package/src/storage/db-types.d.ts +43 -0
  76. package/src/storage/in-memory.test.ts +96 -0
  77. package/src/storage/in-memory.ts +52 -0
  78. package/src/storage/mysql.ts +117 -0
  79. package/src/storage/postgres.ts +117 -0
  80. package/src/storage/sqlite.ts +120 -0
  81. package/tsconfig.json +11 -0
  82. package/vitest.config.ts +32 -0
@@ -0,0 +1,223 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import * as http from "node:http";
3
+ import { WebSocket } from "ws";
4
+ import {
5
+ createServer,
6
+ createWebSocketServer,
7
+ createWebSocketHandler,
8
+ createInMemoryChatStorage,
9
+ } from "./index.js";
10
+ import { MSG_JOIN_ROOM, MSG_ROOM_JOINED, MSG_SEND_CHAT, MSG_CHAT_MESSAGE } from "./protocol.js";
11
+
12
+ describe("WebSocket server integration", () => {
13
+ let server: http.Server;
14
+ let port: number;
15
+ const path = "/live";
16
+
17
+ beforeAll(async () => {
18
+ const storage = createInMemoryChatStorage({ historyLimit: 10 });
19
+ server = http.createServer((_req, res) => {
20
+ res.writeHead(200);
21
+ res.end();
22
+ });
23
+ createWebSocketServer(server, {
24
+ path,
25
+ chat: { storage, historyLimit: 10 },
26
+ });
27
+ await new Promise<void>((resolve) => {
28
+ server.listen(0, () => {
29
+ const addr = server.address();
30
+ port = typeof addr === "object" && addr && "port" in addr ? addr.port : 0;
31
+ resolve();
32
+ });
33
+ });
34
+ });
35
+
36
+ afterAll(() => {
37
+ return new Promise<void>((resolve) => server.close(() => resolve()));
38
+ });
39
+
40
+ it("responds to join_room with room_joined", async () => {
41
+ const ws = new WebSocket(`ws://127.0.0.1:${port}${path}`);
42
+ const messages: unknown[] = [];
43
+ ws.on("message", (data) => messages.push(JSON.parse(data.toString())));
44
+
45
+ await new Promise<void>((resolve) => {
46
+ ws.on("open", () => {
47
+ ws.send(
48
+ JSON.stringify({
49
+ type: MSG_JOIN_ROOM,
50
+ payload: { roomId: "test-room", presence: { name: "alice" } },
51
+ })
52
+ );
53
+ const check = (): void => {
54
+ const roomJoined = messages.find((m: { type?: string }) => m?.type === MSG_ROOM_JOINED);
55
+ if (roomJoined) {
56
+ resolve();
57
+ return;
58
+ }
59
+ setTimeout(check, 20);
60
+ };
61
+ setTimeout(check, 50);
62
+ });
63
+ });
64
+
65
+ const roomJoined = messages.find((m: { type?: string }) => m?.type === MSG_ROOM_JOINED) as {
66
+ type: string;
67
+ payload: {
68
+ roomId: string;
69
+ connectionId: string;
70
+ presence: Record<string, { connectionId: string; presence?: unknown }>;
71
+ };
72
+ };
73
+ expect(roomJoined).toBeDefined();
74
+ expect(roomJoined.payload.roomId).toBe("test-room");
75
+ expect(roomJoined.payload.connectionId).toBeDefined();
76
+ const presenceEntries = Object.values(roomJoined.payload.presence);
77
+ expect(presenceEntries.length).toBe(1);
78
+ expect(presenceEntries[0].connectionId).toBe(roomJoined.payload.connectionId);
79
+
80
+ ws.close();
81
+ });
82
+
83
+ it("broadcasts chat to same room", async () => {
84
+ const storage = createInMemoryChatStorage({ historyLimit: 10 });
85
+ const srv = http.createServer((_req, res) => {
86
+ res.writeHead(200);
87
+ res.end();
88
+ });
89
+ createWebSocketServer(srv, { path: "/chat", chat: { storage, historyLimit: 10 } });
90
+ await new Promise<void>((resolve) => {
91
+ srv.listen(0, () => resolve());
92
+ });
93
+ const addr = srv.address();
94
+ const p = typeof addr === "object" && addr && "port" in addr ? addr.port : 0;
95
+
96
+ const ws1 = new WebSocket(`ws://127.0.0.1:${p}/chat`);
97
+ const msgs1: unknown[] = [];
98
+ ws1.on("message", (data) => msgs1.push(JSON.parse(data.toString())));
99
+
100
+ await new Promise<void>((resolve) => {
101
+ ws1.on("open", () => {
102
+ ws1.send(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "chat-room" } }));
103
+ const check = (): void => {
104
+ if (msgs1.some((m: { type?: string }) => m?.type === MSG_ROOM_JOINED)) {
105
+ resolve();
106
+ return;
107
+ }
108
+ setTimeout(check, 20);
109
+ };
110
+ setTimeout(check, 50);
111
+ });
112
+ });
113
+
114
+ const ws2 = new WebSocket(`ws://127.0.0.1:${p}/chat`);
115
+ const msgs2: unknown[] = [];
116
+ ws2.on("message", (data) => msgs2.push(JSON.parse(data.toString())));
117
+
118
+ await new Promise<void>((resolve) => {
119
+ ws2.on("open", () => {
120
+ ws2.send(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "chat-room" } }));
121
+ const check = (): void => {
122
+ if (msgs2.some((m: { type?: string }) => m?.type === MSG_ROOM_JOINED)) {
123
+ resolve();
124
+ return;
125
+ }
126
+ setTimeout(check, 20);
127
+ };
128
+ setTimeout(check, 50);
129
+ });
130
+ });
131
+
132
+ ws1.send(
133
+ JSON.stringify({ type: MSG_SEND_CHAT, payload: { message: "hello from 1" } })
134
+ );
135
+
136
+ await new Promise<void>((resolve) => {
137
+ const check = (): void => {
138
+ const chatOn2 = msgs2.some(
139
+ (m: { type?: string }) => m?.type === MSG_CHAT_MESSAGE && (m as { payload?: { message?: string } }).payload?.message === "hello from 1"
140
+ );
141
+ if (chatOn2) {
142
+ resolve();
143
+ return;
144
+ }
145
+ setTimeout(check, 50);
146
+ };
147
+ setTimeout(check, 100);
148
+ });
149
+
150
+ const chatMsg = msgs2.find(
151
+ (m: { type?: string; payload?: { message?: string } }) =>
152
+ m?.type === MSG_CHAT_MESSAGE && m?.payload?.message === "hello from 1"
153
+ );
154
+ expect(chatMsg).toBeDefined();
155
+
156
+ ws1.close();
157
+ ws2.close();
158
+ await new Promise<void>((resolve) => srv.close(() => resolve()));
159
+ });
160
+ });
161
+
162
+ describe("createServer and createWebSocketHandler", () => {
163
+ it("createServer returns server with .ws WebSocketServer", async () => {
164
+ const server = createServer({ port: 0 });
165
+ expect(server).toBeDefined();
166
+ expect((server as { ws?: unknown }).ws).toBeDefined();
167
+ await new Promise<void>((resolve) => server.close(() => resolve()));
168
+ });
169
+
170
+ it("createWebSocketHandler ignores upgrade on wrong path", () => {
171
+ const handler = createWebSocketHandler({ path: "/live" });
172
+ const request = { url: "/other" } as http.IncomingMessage;
173
+ const socket = { destroy: () => {} } as import("node:stream").Duplex;
174
+ const head = Buffer.alloc(0);
175
+ expect(() => handler(request, socket, head)).not.toThrow();
176
+ });
177
+
178
+ it("onAuth returning null closes connection with 4401", async () => {
179
+ const srv = http.createServer((_req, res) => {
180
+ res.writeHead(200);
181
+ res.end();
182
+ });
183
+ createWebSocketServer(srv, {
184
+ path: "/auth",
185
+ onAuth: async () => null,
186
+ });
187
+ await new Promise<void>((resolve) => srv.listen(0, () => resolve()));
188
+ const addr = srv.address();
189
+ const port = typeof addr === "object" && addr && "port" in addr ? addr.port : 0;
190
+
191
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/auth`);
192
+ const closeEvent = await new Promise<{ code: number }>((resolve) => {
193
+ ws.on("close", (code: number) => resolve({ code }));
194
+ });
195
+ expect(closeEvent.code).toBe(4401);
196
+
197
+ await new Promise<void>((resolve) => srv.close(() => resolve()));
198
+ });
199
+
200
+ it("onAuth throwing closes connection with 4500", async () => {
201
+ const srv = http.createServer((_req, res) => {
202
+ res.writeHead(200);
203
+ res.end();
204
+ });
205
+ createWebSocketServer(srv, {
206
+ path: "/auth2",
207
+ onAuth: async () => {
208
+ throw new Error("auth failed");
209
+ },
210
+ });
211
+ await new Promise<void>((resolve) => srv.listen(0, () => resolve()));
212
+ const addr = srv.address();
213
+ const port = typeof addr === "object" && addr && "port" in addr ? addr.port : 0;
214
+
215
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/auth2`);
216
+ const closeEvent = await new Promise<{ code: number }>((resolve) => {
217
+ ws.on("close", (code: number) => resolve({ code }));
218
+ });
219
+ expect(closeEvent.code).toBe(4500);
220
+
221
+ await new Promise<void>((resolve) => srv.close(() => resolve()));
222
+ });
223
+ });
package/src/server.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * createServer, createWebSocketServer, createWebSocketHandler.
3
+ */
4
+
5
+ import * as http from "node:http";
6
+ import { randomUUID } from "node:crypto";
7
+ import { WebSocketServer, WebSocket } from "ws";
8
+ import { createInMemoryChatStorage } from "./storage/in-memory.js";
9
+ import type { ChatStorage } from "./storage/chat-storage.js";
10
+ import type { UserInfo } from "./protocol.js";
11
+ import type { AuthOptions } from "./auth/index.js";
12
+ import { RoomManager } from "./room-manager.js";
13
+ import { Connection } from "./connection.js";
14
+
15
+ const DEFAULT_PATH = "/live";
16
+ const DEFAULT_PRESENCE_THROTTLE_MS = 100;
17
+ const DEFAULT_HISTORY_LIMIT = 100;
18
+
19
+ export interface ChatOptions {
20
+ storage?: ChatStorage;
21
+ historyLimit?: number;
22
+ }
23
+
24
+ export interface WebSocketServerOptions {
25
+ /** WebSocket upgrade path (default: "/live"). */
26
+ path?: string;
27
+ /** If provided and returns null, connection is rejected. */
28
+ onAuth?: (request: http.IncomingMessage) => Promise<UserInfo | null>;
29
+ /** Optional: decode/verify access tokens in join_room (Google, Microsoft, custom OAuth). */
30
+ auth?: AuthOptions;
31
+ /** Min interval between presence updates per connection in ms (default: 100). */
32
+ presenceThrottleMs?: number;
33
+ /** Chat storage and history limit. If storage omitted, uses in-memory. */
34
+ chat?: ChatOptions;
35
+ }
36
+
37
+ export interface ServerOptions extends WebSocketServerOptions {
38
+ /** Port for standalone server (default: 3000). */
39
+ port?: number;
40
+ }
41
+
42
+ function randomConnectionId(): string {
43
+ return randomUUID();
44
+ }
45
+
46
+ function createRoomManager(options: WebSocketServerOptions): RoomManager {
47
+ const chat = options.chat ?? {};
48
+ const storage = chat.storage ?? createInMemoryChatStorage({ historyLimit: chat.historyLimit ?? DEFAULT_HISTORY_LIMIT });
49
+ const historyLimit = chat.historyLimit ?? DEFAULT_HISTORY_LIMIT;
50
+ return new RoomManager({ chatStorage: storage, historyLimit });
51
+ }
52
+
53
+ function handleUpgrade(
54
+ wss: WebSocketServer,
55
+ options: WebSocketServerOptions,
56
+ roomManager: RoomManager,
57
+ request: http.IncomingMessage,
58
+ socket: import("node:stream").Duplex,
59
+ head: Buffer
60
+ ): void {
61
+ const path = options.path ?? DEFAULT_PATH;
62
+ const url = request.url ?? "";
63
+ const pathname = url.split("?")[0];
64
+ if (pathname !== path) return;
65
+
66
+ wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {
67
+ wss.emit("connection", ws, request);
68
+ const connectionId = randomConnectionId();
69
+ let authResult: UserInfo | null = {};
70
+
71
+ const proceed = (): void => {
72
+ const presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
73
+ new Connection(ws, {
74
+ connectionId,
75
+ userId: authResult?.userId,
76
+ userName: authResult?.name,
77
+ userEmail: authResult?.email,
78
+ provider: authResult?.provider,
79
+ presenceThrottleMs,
80
+ roomManager,
81
+ auth: options.auth,
82
+ });
83
+ };
84
+
85
+ if (options.onAuth) {
86
+ options.onAuth(request).then((result) => {
87
+ authResult = result;
88
+ if (result === null) {
89
+ ws.close(4401, "Unauthorized");
90
+ return;
91
+ }
92
+ proceed();
93
+ }).catch(() => {
94
+ ws.close(4500, "Auth error");
95
+ });
96
+ } else {
97
+ proceed();
98
+ }
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Returns the raw upgrade handler. Attach to your HTTP server with
104
+ * server.on('upgrade', handler).
105
+ */
106
+ export function createWebSocketHandler(
107
+ options: WebSocketServerOptions = {}
108
+ ): (request: http.IncomingMessage, socket: import("node:stream").Duplex, head: Buffer) => void {
109
+ const roomManager = createRoomManager(options);
110
+ const wss = new WebSocketServer({ noServer: true });
111
+ const path = options.path ?? DEFAULT_PATH;
112
+
113
+ return (request: http.IncomingMessage, socket: import("node:stream").Duplex, head: Buffer) => {
114
+ const url = request.url ?? "";
115
+ const pathname = url.split("?")[0];
116
+ if (pathname !== path) return;
117
+ handleUpgrade(wss, options, roomManager, request, socket, head);
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Attaches WebSocket upgrade handling to an existing Node HTTP server.
123
+ * Returns the WebSocketServer instance (e.g. for closing later).
124
+ */
125
+ export function createWebSocketServer(
126
+ server: http.Server,
127
+ options: WebSocketServerOptions = {}
128
+ ): WebSocketServer {
129
+ const roomManager = createRoomManager(options);
130
+ const wss = new WebSocketServer({ noServer: true });
131
+
132
+ server.on("upgrade", (request: http.IncomingMessage, socket: import("node:stream").Duplex, head: Buffer) => {
133
+ handleUpgrade(wss, options, roomManager, request, socket, head);
134
+ });
135
+
136
+ return wss;
137
+ }
138
+
139
+ /**
140
+ * Creates an HTTP server with a simple root handler and WebSocket support.
141
+ * Returns the server and the WebSocketServer (as server.ws).
142
+ */
143
+ export function createServer(options: ServerOptions = {}): http.Server & { ws: WebSocketServer } {
144
+ const port = options.port ?? 3000;
145
+ const server = http.createServer((_req, res) => {
146
+ res.writeHead(200, { "Content-Type": "text/plain" });
147
+ res.end("openlivesync");
148
+ });
149
+ const wss = createWebSocketServer(server, options);
150
+ (server as http.Server & { ws: WebSocketServer }).ws = wss;
151
+ server.listen(port);
152
+ return server as http.Server & { ws: WebSocketServer };
153
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Chat storage interface for pluggable persistence.
3
+ * Implementations: in-memory, Postgres, MySQL, SQLite.
4
+ */
5
+
6
+ import type { ChatMessageInput, StoredChatMessage } from "../protocol.js";
7
+
8
+ export type { ChatMessageInput };
9
+
10
+ /**
11
+ * Pluggable chat storage. Pass an implementation into server options
12
+ * via chat.storage. Omit to use default in-memory storage.
13
+ */
14
+ export interface ChatStorage {
15
+ /** Append a message to the room's history. */
16
+ append(roomId: string, message: ChatMessageInput): Promise<void>;
17
+
18
+ /** Get messages for a room. Order: oldest first. Use limit and optional offset for pagination. */
19
+ getHistory(roomId: string, limit?: number, offset?: number): Promise<StoredChatMessage[]>;
20
+
21
+ /** Optional: release connections / cleanup. */
22
+ close?(): Promise<void>;
23
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Minimal type declarations for optional DB drivers.
3
+ * Install pg / mysql2 / better-sqlite3 only when using those adapters.
4
+ */
5
+ declare module "pg" {
6
+ export interface PoolConfig {
7
+ connectionString?: string;
8
+ host?: string;
9
+ port?: number;
10
+ database?: string;
11
+ user?: string;
12
+ password?: string;
13
+ [key: string]: unknown;
14
+ }
15
+ export class Pool {
16
+ constructor(config?: PoolConfig);
17
+ query(text: string, values?: unknown[]): Promise<{ rows: unknown[] }>;
18
+ end(): Promise<void>;
19
+ }
20
+ }
21
+
22
+ declare module "mysql2/promise" {
23
+ export function createPool(config: Record<string, unknown>): {
24
+ query<T>(sql: string, values?: unknown[]): Promise<[T]>;
25
+ end(): Promise<void>;
26
+ };
27
+ }
28
+
29
+ declare module "better-sqlite3" {
30
+ interface BetterSqlite3Database {
31
+ exec(sql: string): this;
32
+ prepare(sql: string): {
33
+ run(...params: unknown[]): unknown;
34
+ all(...params: unknown[]): unknown[];
35
+ };
36
+ close(): void;
37
+ }
38
+ interface DatabaseConstructor {
39
+ new (filename: string, options?: Record<string, unknown>): BetterSqlite3Database;
40
+ }
41
+ const Database: DatabaseConstructor;
42
+ export default Database;
43
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createInMemoryChatStorage } from "./in-memory.js";
3
+
4
+ describe("createInMemoryChatStorage", () => {
5
+ it("appends messages and returns them oldest first", async () => {
6
+ const storage = createInMemoryChatStorage({ historyLimit: 10 });
7
+ await storage.append("room1", {
8
+ roomId: "room1",
9
+ connectionId: "c1",
10
+ message: "first",
11
+ });
12
+ await storage.append("room1", {
13
+ roomId: "room1",
14
+ connectionId: "c2",
15
+ message: "second",
16
+ });
17
+ const history = await storage.getHistory("room1");
18
+ expect(history).toHaveLength(2);
19
+ expect(history[0].message).toBe("first");
20
+ expect(history[1].message).toBe("second");
21
+ expect(history[0].connectionId).toBe("c1");
22
+ expect(history[1].connectionId).toBe("c2");
23
+ });
24
+
25
+ it("respects limit", async () => {
26
+ const storage = createInMemoryChatStorage({ historyLimit: 100 });
27
+ for (let i = 0; i < 5; i++) {
28
+ await storage.append("room1", {
29
+ roomId: "room1",
30
+ connectionId: "c1",
31
+ message: `msg${i}`,
32
+ });
33
+ }
34
+ const all = await storage.getHistory("room1", 100);
35
+ expect(all).toHaveLength(5);
36
+ const limited = await storage.getHistory("room1", 2);
37
+ expect(limited).toHaveLength(2);
38
+ expect(limited[0].message).toBe("msg0");
39
+ expect(limited[1].message).toBe("msg1");
40
+ });
41
+
42
+ it("respects offset for pagination", async () => {
43
+ const storage = createInMemoryChatStorage({ historyLimit: 100 });
44
+ for (let i = 0; i < 5; i++) {
45
+ await storage.append("room1", {
46
+ roomId: "room1",
47
+ connectionId: "c1",
48
+ message: `msg${i}`,
49
+ });
50
+ }
51
+ const page = await storage.getHistory("room1", 2, 2);
52
+ expect(page).toHaveLength(2);
53
+ expect(page[0].message).toBe("msg2");
54
+ expect(page[1].message).toBe("msg3");
55
+ });
56
+
57
+ it("returns empty for unknown room", async () => {
58
+ const storage = createInMemoryChatStorage();
59
+ const history = await storage.getHistory("nonexistent");
60
+ expect(history).toEqual([]);
61
+ });
62
+
63
+ it("keeps at most historyLimit messages per room", async () => {
64
+ const storage = createInMemoryChatStorage({ historyLimit: 3 });
65
+ for (let i = 0; i < 5; i++) {
66
+ await storage.append("room1", {
67
+ roomId: "room1",
68
+ connectionId: "c1",
69
+ message: `msg${i}`,
70
+ });
71
+ }
72
+ const history = await storage.getHistory("room1", 10);
73
+ expect(history).toHaveLength(3);
74
+ expect(history.map((m) => m.message)).toEqual(["msg2", "msg3", "msg4"]);
75
+ });
76
+
77
+ it("isolates rooms", async () => {
78
+ const storage = createInMemoryChatStorage({ historyLimit: 10 });
79
+ await storage.append("room1", {
80
+ roomId: "room1",
81
+ connectionId: "c1",
82
+ message: "in room1",
83
+ });
84
+ await storage.append("room2", {
85
+ roomId: "room2",
86
+ connectionId: "c1",
87
+ message: "in room2",
88
+ });
89
+ const h1 = await storage.getHistory("room1");
90
+ const h2 = await storage.getHistory("room2");
91
+ expect(h1).toHaveLength(1);
92
+ expect(h2).toHaveLength(1);
93
+ expect(h1[0].message).toBe("in room1");
94
+ expect(h2[0].message).toBe("in room2");
95
+ });
96
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * In-memory chat storage. Keeps last N messages per room.
3
+ * No DB required; default when no storage is configured.
4
+ */
5
+
6
+ import type { ChatMessageInput, StoredChatMessage } from "../protocol.js";
7
+ import type { ChatStorage } from "./chat-storage.js";
8
+
9
+ export interface InMemoryChatStorageOptions {
10
+ /** Max messages to keep per room (default 100). */
11
+ historyLimit?: number;
12
+ }
13
+
14
+ const DEFAULT_HISTORY_LIMIT = 100;
15
+
16
+ export function createInMemoryChatStorage(
17
+ options: InMemoryChatStorageOptions = {}
18
+ ): ChatStorage {
19
+ const historyLimit = options.historyLimit ?? DEFAULT_HISTORY_LIMIT;
20
+ const store = new Map<string, StoredChatMessage[]>();
21
+ let idCounter = 0;
22
+
23
+ return {
24
+ async append(roomId: string, message: ChatMessageInput): Promise<void> {
25
+ const list = store.get(roomId) ?? [];
26
+ const stored: StoredChatMessage = {
27
+ id: `msg_${++idCounter}_${Date.now()}`,
28
+ roomId,
29
+ connectionId: message.connectionId,
30
+ userId: message.userId,
31
+ message: message.message,
32
+ metadata: message.metadata,
33
+ createdAt: Date.now(),
34
+ };
35
+ list.push(stored);
36
+ if (list.length > historyLimit) {
37
+ list.splice(0, list.length - historyLimit);
38
+ }
39
+ store.set(roomId, list);
40
+ },
41
+
42
+ async getHistory(
43
+ roomId: string,
44
+ limit: number = historyLimit,
45
+ offset: number = 0
46
+ ): Promise<StoredChatMessage[]> {
47
+ const list = store.get(roomId) ?? [];
48
+ const start = Math.max(0, offset);
49
+ return list.slice(start, start + limit);
50
+ },
51
+ };
52
+ }