@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.
- package/README.md +366 -0
- package/dist/auth/decode-token.d.ts +40 -0
- package/dist/auth/decode-token.d.ts.map +1 -0
- package/dist/auth/decode-token.js +93 -0
- package/dist/auth/decode-token.js.map +1 -0
- package/dist/auth/index.d.ts +6 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +6 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/token-auth.d.ts +18 -0
- package/dist/auth/token-auth.d.ts.map +1 -0
- package/dist/auth/token-auth.js +45 -0
- package/dist/auth/token-auth.js.map +1 -0
- package/dist/connection.d.ts +36 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +170 -0
- package/dist/connection.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +135 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +17 -0
- package/dist/protocol.js.map +1 -0
- package/dist/room-manager.d.ts +19 -0
- package/dist/room-manager.d.ts.map +1 -0
- package/dist/room-manager.js +34 -0
- package/dist/room-manager.js.map +1 -0
- package/dist/room.d.ts +41 -0
- package/dist/room.d.ts.map +1 -0
- package/dist/room.js +150 -0
- package/dist/room.js.map +1 -0
- package/dist/server.d.ts +46 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +105 -0
- package/dist/server.js.map +1 -0
- package/dist/storage/chat-storage.d.ts +19 -0
- package/dist/storage/chat-storage.d.ts.map +1 -0
- package/dist/storage/chat-storage.js +6 -0
- package/dist/storage/chat-storage.js.map +1 -0
- package/dist/storage/in-memory.d.ts +11 -0
- package/dist/storage/in-memory.d.ts.map +1 -0
- package/dist/storage/in-memory.js +35 -0
- package/dist/storage/in-memory.js.map +1 -0
- package/dist/storage/mysql.d.ts +19 -0
- package/dist/storage/mysql.d.ts.map +1 -0
- package/dist/storage/mysql.js +70 -0
- package/dist/storage/mysql.js.map +1 -0
- package/dist/storage/postgres.d.ts +21 -0
- package/dist/storage/postgres.d.ts.map +1 -0
- package/dist/storage/postgres.js +70 -0
- package/dist/storage/postgres.js.map +1 -0
- package/dist/storage/sqlite.d.ts +15 -0
- package/dist/storage/sqlite.d.ts.map +1 -0
- package/dist/storage/sqlite.js +71 -0
- package/dist/storage/sqlite.js.map +1 -0
- package/package.json +51 -0
- package/src/auth/decode-token.test.ts +119 -0
- package/src/auth/decode-token.ts +138 -0
- package/src/auth/index.ts +16 -0
- package/src/auth/token-auth.test.ts +95 -0
- package/src/auth/token-auth.ts +55 -0
- package/src/connection.test.ts +339 -0
- package/src/connection.ts +204 -0
- package/src/index.ts +80 -0
- package/src/protocol.test.ts +29 -0
- package/src/protocol.ts +137 -0
- package/src/room-manager.ts +45 -0
- package/src/room.test.ts +175 -0
- package/src/room.ts +207 -0
- package/src/server.test.ts +223 -0
- package/src/server.ts +153 -0
- package/src/storage/chat-storage.ts +23 -0
- package/src/storage/db-types.d.ts +43 -0
- package/src/storage/in-memory.test.ts +96 -0
- package/src/storage/in-memory.ts +52 -0
- package/src/storage/mysql.ts +117 -0
- package/src/storage/postgres.ts +117 -0
- package/src/storage/sqlite.ts +120 -0
- package/tsconfig.json +11 -0
- package/vitest.config.ts +32 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { Connection } from "./connection.js";
|
|
3
|
+
import { RoomManager } from "./room-manager.js";
|
|
4
|
+
import { createInMemoryChatStorage } from "./storage/in-memory.js";
|
|
5
|
+
import {
|
|
6
|
+
MSG_JOIN_ROOM,
|
|
7
|
+
MSG_LEAVE_ROOM,
|
|
8
|
+
MSG_UPDATE_PRESENCE,
|
|
9
|
+
MSG_BROADCAST_EVENT,
|
|
10
|
+
MSG_SEND_CHAT,
|
|
11
|
+
MSG_ERROR,
|
|
12
|
+
MSG_ROOM_JOINED,
|
|
13
|
+
MSG_CHAT_MESSAGE,
|
|
14
|
+
MSG_BROADCAST_EVENT_RELAY,
|
|
15
|
+
} from "./protocol.js";
|
|
16
|
+
import { SignJWT } from "jose";
|
|
17
|
+
|
|
18
|
+
function createMockWs(): {
|
|
19
|
+
ws: {
|
|
20
|
+
readyState: number;
|
|
21
|
+
on: (ev: string, fn: (data?: unknown) => void) => void;
|
|
22
|
+
emit: (ev: string, data?: unknown) => void;
|
|
23
|
+
send: (data: string) => void;
|
|
24
|
+
close: () => void;
|
|
25
|
+
};
|
|
26
|
+
sent: unknown[];
|
|
27
|
+
emitMessage: (data: string | Buffer) => void;
|
|
28
|
+
emitClose: () => void;
|
|
29
|
+
} {
|
|
30
|
+
const listeners: Record<string, (data?: unknown) => void> = {};
|
|
31
|
+
const sent: unknown[] = [];
|
|
32
|
+
return {
|
|
33
|
+
ws: {
|
|
34
|
+
readyState: 1,
|
|
35
|
+
OPEN: 1,
|
|
36
|
+
on(ev: string, fn: (data?: unknown) => void) {
|
|
37
|
+
listeners[ev] = fn;
|
|
38
|
+
},
|
|
39
|
+
emit(ev: string, data?: unknown) {
|
|
40
|
+
if (listeners[ev]) listeners[ev](data);
|
|
41
|
+
},
|
|
42
|
+
send(data: string) {
|
|
43
|
+
sent.push(JSON.parse(data));
|
|
44
|
+
},
|
|
45
|
+
close() {
|
|
46
|
+
listeners["close"]?.();
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
sent,
|
|
50
|
+
emitMessage(data: string | Buffer) {
|
|
51
|
+
const raw = typeof data === "string" ? data : data.toString("utf8");
|
|
52
|
+
listeners["message"]?.(raw);
|
|
53
|
+
},
|
|
54
|
+
emitClose() {
|
|
55
|
+
listeners["close"]?.();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("Connection", () => {
|
|
61
|
+
let mock: ReturnType<typeof createMockWs>;
|
|
62
|
+
let roomManager: RoomManager;
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
mock = createMockWs();
|
|
66
|
+
const storage = createInMemoryChatStorage({ historyLimit: 10 });
|
|
67
|
+
roomManager = new RoomManager({ chatStorage: storage, historyLimit: 10 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("sends INVALID_JSON error for invalid JSON", () => {
|
|
71
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
72
|
+
connectionId: "c1",
|
|
73
|
+
presenceThrottleMs: 0,
|
|
74
|
+
roomManager,
|
|
75
|
+
});
|
|
76
|
+
mock.emitMessage("not json");
|
|
77
|
+
expect(mock.sent).toHaveLength(1);
|
|
78
|
+
expect(mock.sent[0]).toMatchObject({
|
|
79
|
+
type: MSG_ERROR,
|
|
80
|
+
payload: { code: "INVALID_JSON", message: "Invalid JSON" },
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("sends INVALID_MESSAGE error for unknown message type", () => {
|
|
85
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
86
|
+
connectionId: "c1",
|
|
87
|
+
presenceThrottleMs: 0,
|
|
88
|
+
roomManager,
|
|
89
|
+
});
|
|
90
|
+
mock.emitMessage(JSON.stringify({ type: "unknown", payload: {} }));
|
|
91
|
+
expect(mock.sent).toHaveLength(1);
|
|
92
|
+
expect(mock.sent[0]).toMatchObject({
|
|
93
|
+
type: MSG_ERROR,
|
|
94
|
+
payload: { code: "INVALID_MESSAGE", message: "Unknown or invalid message type" },
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("join_room sends room_joined and leave_room cleans up", async () => {
|
|
99
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
100
|
+
connectionId: "c1",
|
|
101
|
+
presenceThrottleMs: 0,
|
|
102
|
+
roomManager,
|
|
103
|
+
});
|
|
104
|
+
mock.emitMessage(
|
|
105
|
+
JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "r1", presence: { x: 1 } } })
|
|
106
|
+
);
|
|
107
|
+
await vi.waitFor(() => {
|
|
108
|
+
expect(mock.sent.some((m: { type?: string }) => m?.type === MSG_ROOM_JOINED)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
expect(roomManager.get("r1")?.connectionCount).toBe(1);
|
|
111
|
+
mock.emitMessage(JSON.stringify({ type: MSG_LEAVE_ROOM, payload: { roomId: "r1" } }));
|
|
112
|
+
await vi.waitFor(() => {
|
|
113
|
+
expect(roomManager.get("r1")).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("update_presence is throttled when throttleMs > 0", async () => {
|
|
118
|
+
const mock1 = createMockWs();
|
|
119
|
+
const mock2 = createMockWs();
|
|
120
|
+
new Connection(mock1.ws as import("ws").WebSocket, {
|
|
121
|
+
connectionId: "c1",
|
|
122
|
+
presenceThrottleMs: 10000,
|
|
123
|
+
roomManager,
|
|
124
|
+
});
|
|
125
|
+
new Connection(mock2.ws as import("ws").WebSocket, {
|
|
126
|
+
connectionId: "c2",
|
|
127
|
+
presenceThrottleMs: 10000,
|
|
128
|
+
roomManager,
|
|
129
|
+
});
|
|
130
|
+
mock1.emitMessage(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "r1" } }));
|
|
131
|
+
mock2.emitMessage(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "r1" } }));
|
|
132
|
+
await vi.waitFor(() => {
|
|
133
|
+
expect(mock2.sent.some((m: { type?: string }) => m?.type === MSG_ROOM_JOINED)).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
mock2.sent.length = 0;
|
|
136
|
+
mock1.emitMessage(JSON.stringify({ type: MSG_UPDATE_PRESENCE, payload: { presence: { a: 1 } } }));
|
|
137
|
+
mock1.emitMessage(JSON.stringify({ type: MSG_UPDATE_PRESENCE, payload: { presence: { a: 2 } } }));
|
|
138
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
139
|
+
const presenceUpdates = mock2.sent.filter(
|
|
140
|
+
(m: { type?: string }) => m?.type === "presence_updated"
|
|
141
|
+
);
|
|
142
|
+
expect(presenceUpdates.length).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("broadcast_event when not in room does nothing", () => {
|
|
146
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
147
|
+
connectionId: "c1",
|
|
148
|
+
presenceThrottleMs: 0,
|
|
149
|
+
roomManager,
|
|
150
|
+
});
|
|
151
|
+
mock.emitMessage(
|
|
152
|
+
JSON.stringify({
|
|
153
|
+
type: MSG_BROADCAST_EVENT,
|
|
154
|
+
payload: { event: "draw", payload: { x: 1 } },
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
expect(mock.sent).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("send_chat when not in room does nothing", async () => {
|
|
161
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
162
|
+
connectionId: "c1",
|
|
163
|
+
presenceThrottleMs: 0,
|
|
164
|
+
roomManager,
|
|
165
|
+
});
|
|
166
|
+
mock.emitMessage(
|
|
167
|
+
JSON.stringify({ type: MSG_SEND_CHAT, payload: { message: "hi" } })
|
|
168
|
+
);
|
|
169
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
170
|
+
expect(mock.sent).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("handleClose leaves room and removes if empty", async () => {
|
|
174
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
175
|
+
connectionId: "c1",
|
|
176
|
+
presenceThrottleMs: 0,
|
|
177
|
+
roomManager,
|
|
178
|
+
});
|
|
179
|
+
mock.emitMessage(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "r1" } }));
|
|
180
|
+
await vi.waitFor(() => {
|
|
181
|
+
expect(roomManager.get("r1")?.connectionCount).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
mock.emitClose();
|
|
184
|
+
expect(roomManager.get("r1")).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("dispatch error sends SERVER_ERROR", async () => {
|
|
188
|
+
const fakeRoom = {
|
|
189
|
+
connectionCount: 0,
|
|
190
|
+
join: () => Promise.reject(new Error("join failed")),
|
|
191
|
+
leave: () => {},
|
|
192
|
+
updatePresence: () => {},
|
|
193
|
+
broadcastEvent: () => {},
|
|
194
|
+
sendChat: () => Promise.resolve(),
|
|
195
|
+
};
|
|
196
|
+
const roomManagerWithFailingJoin = {
|
|
197
|
+
get: () => undefined,
|
|
198
|
+
getOrCreate: () => fakeRoom as import("./room.js").Room,
|
|
199
|
+
removeIfEmpty: () => {},
|
|
200
|
+
};
|
|
201
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
202
|
+
connectionId: "c1",
|
|
203
|
+
presenceThrottleMs: 0,
|
|
204
|
+
roomManager: roomManagerWithFailingJoin as unknown as RoomManager,
|
|
205
|
+
});
|
|
206
|
+
mock.emitMessage(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "r1" } }));
|
|
207
|
+
await vi.waitFor(() => {
|
|
208
|
+
const errMsg = mock.sent.find((m: { type?: string }) => m?.type === MSG_ERROR);
|
|
209
|
+
expect(errMsg).toBeDefined();
|
|
210
|
+
expect((errMsg as { payload?: { message?: string } }).payload?.message).toBe("join failed");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("leave_room with no current room does nothing", () => {
|
|
215
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
216
|
+
connectionId: "c1",
|
|
217
|
+
presenceThrottleMs: 0,
|
|
218
|
+
roomManager,
|
|
219
|
+
});
|
|
220
|
+
mock.emitMessage(JSON.stringify({ type: MSG_LEAVE_ROOM }));
|
|
221
|
+
expect(mock.sent).toHaveLength(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("ignores messages after close", () => {
|
|
225
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
226
|
+
connectionId: "c1",
|
|
227
|
+
presenceThrottleMs: 0,
|
|
228
|
+
roomManager,
|
|
229
|
+
});
|
|
230
|
+
mock.emitClose();
|
|
231
|
+
mock.emitMessage(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "r1" } }));
|
|
232
|
+
expect(mock.sent).toHaveLength(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("joining another room leaves current room and removeIfEmpty clears it", async () => {
|
|
236
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
237
|
+
connectionId: "c1",
|
|
238
|
+
presenceThrottleMs: 0,
|
|
239
|
+
roomManager,
|
|
240
|
+
});
|
|
241
|
+
mock.emitMessage(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "r1" } }));
|
|
242
|
+
await vi.waitFor(() => {
|
|
243
|
+
expect(roomManager.get("r1")?.connectionCount).toBe(1);
|
|
244
|
+
});
|
|
245
|
+
mock.emitMessage(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "r2" } }));
|
|
246
|
+
await vi.waitFor(() => {
|
|
247
|
+
expect(mock.sent.some((m: { type?: string }) => m?.type === MSG_ROOM_JOINED)).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
expect(roomManager.get("r1")).toBeUndefined();
|
|
250
|
+
expect(roomManager.get("r2")?.connectionCount).toBe(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("broadcast_event when in room forwards to room", async () => {
|
|
254
|
+
const mock2 = createMockWs();
|
|
255
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
256
|
+
connectionId: "c1",
|
|
257
|
+
presenceThrottleMs: 0,
|
|
258
|
+
roomManager,
|
|
259
|
+
});
|
|
260
|
+
new Connection(mock2.ws as import("ws").WebSocket, {
|
|
261
|
+
connectionId: "c2",
|
|
262
|
+
presenceThrottleMs: 0,
|
|
263
|
+
roomManager,
|
|
264
|
+
});
|
|
265
|
+
mock.emitMessage(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "br" } }));
|
|
266
|
+
mock2.emitMessage(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "br" } }));
|
|
267
|
+
await vi.waitFor(() => {
|
|
268
|
+
expect(mock2.sent.some((m: { type?: string }) => m?.type === MSG_ROOM_JOINED)).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
mock2.sent.length = 0;
|
|
271
|
+
mock.emitMessage(
|
|
272
|
+
JSON.stringify({
|
|
273
|
+
type: MSG_BROADCAST_EVENT,
|
|
274
|
+
payload: { event: "draw", payload: { x: 1 } },
|
|
275
|
+
})
|
|
276
|
+
);
|
|
277
|
+
await vi.waitFor(() => {
|
|
278
|
+
expect(mock2.sent.some((m: { type?: string }) => m?.type === MSG_BROADCAST_EVENT_RELAY)).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("send_chat when in room appends and broadcasts", async () => {
|
|
283
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
284
|
+
connectionId: "c1",
|
|
285
|
+
presenceThrottleMs: 0,
|
|
286
|
+
roomManager,
|
|
287
|
+
});
|
|
288
|
+
mock.emitMessage(JSON.stringify({ type: MSG_JOIN_ROOM, payload: { roomId: "cr" } }));
|
|
289
|
+
await vi.waitFor(() => {
|
|
290
|
+
expect(mock.sent.some((m: { type?: string }) => m?.type === MSG_ROOM_JOINED)).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
mock.emitMessage(JSON.stringify({ type: MSG_SEND_CHAT, payload: { message: "hello room" } }));
|
|
293
|
+
await vi.waitFor(() => {
|
|
294
|
+
const chatMsg = mock.sent.find(
|
|
295
|
+
(m: { type?: string; payload?: { message?: string } }) =>
|
|
296
|
+
m?.type === MSG_CHAT_MESSAGE && m?.payload?.message === "hello room"
|
|
297
|
+
);
|
|
298
|
+
expect(chatMsg).toBeDefined();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("join_room with accessToken sets userId, name, email, provider from decoded token", async () => {
|
|
303
|
+
const secret = new TextEncoder().encode("auth-secret");
|
|
304
|
+
const token = await new SignJWT({
|
|
305
|
+
sub: "auth-user-1",
|
|
306
|
+
name: "Auth User",
|
|
307
|
+
email: "auth@example.com",
|
|
308
|
+
iss: "https://accounts.google.com",
|
|
309
|
+
})
|
|
310
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
311
|
+
.setIssuedAt()
|
|
312
|
+
.setExpirationTime("1h")
|
|
313
|
+
.sign(secret);
|
|
314
|
+
|
|
315
|
+
new Connection(mock.ws as import("ws").WebSocket, {
|
|
316
|
+
connectionId: "c1",
|
|
317
|
+
presenceThrottleMs: 0,
|
|
318
|
+
roomManager,
|
|
319
|
+
auth: {},
|
|
320
|
+
});
|
|
321
|
+
mock.emitMessage(
|
|
322
|
+
JSON.stringify({
|
|
323
|
+
type: MSG_JOIN_ROOM,
|
|
324
|
+
payload: { roomId: "r1", presence: {}, accessToken: token },
|
|
325
|
+
})
|
|
326
|
+
);
|
|
327
|
+
await vi.waitFor(() => {
|
|
328
|
+
expect(mock.sent.some((m: { type?: string }) => m?.type === MSG_ROOM_JOINED)).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
const roomJoined = mock.sent.find((m: { type?: string }) => m?.type === MSG_ROOM_JOINED) as {
|
|
331
|
+
payload?: { presence?: Record<string, { userId?: string; name?: string; email?: string; provider?: string }> };
|
|
332
|
+
};
|
|
333
|
+
const selfEntry = roomJoined?.payload?.presence?.["c1"];
|
|
334
|
+
expect(selfEntry?.userId).toBe("auth-user-1");
|
|
335
|
+
expect(selfEntry?.name).toBe("Auth User");
|
|
336
|
+
expect(selfEntry?.email).toBe("auth@example.com");
|
|
337
|
+
expect(selfEntry?.provider).toBe("google");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps a WebSocket: parse messages, throttle presence, dispatch to room.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { WebSocket } from "ws";
|
|
6
|
+
import type {
|
|
7
|
+
ClientMessage,
|
|
8
|
+
ServerMessage,
|
|
9
|
+
Presence,
|
|
10
|
+
} from "./protocol.js";
|
|
11
|
+
import {
|
|
12
|
+
MSG_JOIN_ROOM,
|
|
13
|
+
MSG_LEAVE_ROOM,
|
|
14
|
+
MSG_UPDATE_PRESENCE,
|
|
15
|
+
MSG_BROADCAST_EVENT,
|
|
16
|
+
MSG_SEND_CHAT,
|
|
17
|
+
MSG_ERROR,
|
|
18
|
+
} from "./protocol.js";
|
|
19
|
+
import type { RoomManager } from "./room-manager.js";
|
|
20
|
+
import type { RoomConnectionHandle } from "./room.js";
|
|
21
|
+
import { decodeAccessToken } from "./auth/index.js";
|
|
22
|
+
import type { AuthOptions } from "./auth/index.js";
|
|
23
|
+
|
|
24
|
+
function isClientMessage(msg: unknown): msg is ClientMessage {
|
|
25
|
+
if (msg === null || typeof msg !== "object" || !("type" in msg)) return false;
|
|
26
|
+
const t = (msg as { type: string }).type;
|
|
27
|
+
return [
|
|
28
|
+
MSG_JOIN_ROOM,
|
|
29
|
+
MSG_LEAVE_ROOM,
|
|
30
|
+
MSG_UPDATE_PRESENCE,
|
|
31
|
+
MSG_BROADCAST_EVENT,
|
|
32
|
+
MSG_SEND_CHAT,
|
|
33
|
+
].includes(t);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function send(ws: WebSocket, msg: ServerMessage): void {
|
|
37
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
38
|
+
ws.send(JSON.stringify(msg));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ConnectionOptions {
|
|
42
|
+
connectionId: string;
|
|
43
|
+
userId?: string;
|
|
44
|
+
userName?: string;
|
|
45
|
+
userEmail?: string;
|
|
46
|
+
provider?: string;
|
|
47
|
+
presenceThrottleMs: number;
|
|
48
|
+
roomManager: RoomManager;
|
|
49
|
+
auth?: AuthOptions;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class Connection {
|
|
53
|
+
private readonly ws: WebSocket;
|
|
54
|
+
private readonly connectionId: string;
|
|
55
|
+
private userId: string | undefined;
|
|
56
|
+
private userName: string | undefined;
|
|
57
|
+
private userEmail: string | undefined;
|
|
58
|
+
private provider: string | undefined;
|
|
59
|
+
private readonly presenceThrottleMs: number;
|
|
60
|
+
private readonly roomManager: RoomManager;
|
|
61
|
+
private readonly auth: AuthOptions | undefined;
|
|
62
|
+
private currentRoomId: string | null = null;
|
|
63
|
+
private lastPresenceUpdate = 0;
|
|
64
|
+
private closed = false;
|
|
65
|
+
|
|
66
|
+
constructor(ws: WebSocket, options: ConnectionOptions) {
|
|
67
|
+
this.ws = ws;
|
|
68
|
+
this.connectionId = options.connectionId;
|
|
69
|
+
this.userId = options.userId;
|
|
70
|
+
this.userName = options.userName;
|
|
71
|
+
this.userEmail = options.userEmail;
|
|
72
|
+
this.provider = options.provider;
|
|
73
|
+
this.presenceThrottleMs = options.presenceThrottleMs;
|
|
74
|
+
this.roomManager = options.roomManager;
|
|
75
|
+
this.auth = options.auth;
|
|
76
|
+
|
|
77
|
+
this.ws.on("message", (data: Buffer | string) => this.handleMessage(data));
|
|
78
|
+
this.ws.on("close", () => this.handleClose());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private send(msg: ServerMessage): void {
|
|
82
|
+
send(this.ws, msg);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private handleMessage(data: Buffer | string): void {
|
|
86
|
+
if (this.closed) return;
|
|
87
|
+
let msg: unknown;
|
|
88
|
+
try {
|
|
89
|
+
const raw = typeof data === "string" ? data : data.toString("utf8");
|
|
90
|
+
msg = JSON.parse(raw) as unknown;
|
|
91
|
+
} catch {
|
|
92
|
+
this.send({
|
|
93
|
+
type: MSG_ERROR,
|
|
94
|
+
payload: { code: "INVALID_JSON", message: "Invalid JSON" },
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!isClientMessage(msg)) {
|
|
99
|
+
this.send({
|
|
100
|
+
type: MSG_ERROR,
|
|
101
|
+
payload: { code: "INVALID_MESSAGE", message: "Unknown or invalid message type" },
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.dispatch(msg).catch((err) => {
|
|
106
|
+
this.send({
|
|
107
|
+
type: MSG_ERROR,
|
|
108
|
+
payload: {
|
|
109
|
+
code: "SERVER_ERROR",
|
|
110
|
+
message: err instanceof Error ? err.message : String(err),
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async dispatch(clientMsg: ClientMessage): Promise<void> {
|
|
117
|
+
switch (clientMsg.type) {
|
|
118
|
+
case MSG_JOIN_ROOM: {
|
|
119
|
+
const { roomId, presence, accessToken } = clientMsg.payload;
|
|
120
|
+
if (accessToken && this.userId === undefined) {
|
|
121
|
+
const decoded = await decodeAccessToken(accessToken, this.auth);
|
|
122
|
+
if (decoded) {
|
|
123
|
+
this.userId = decoded.sub;
|
|
124
|
+
this.userName = decoded.name;
|
|
125
|
+
this.userEmail = decoded.email;
|
|
126
|
+
this.provider = decoded.provider;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (this.currentRoomId) {
|
|
130
|
+
const room = this.roomManager.get(this.currentRoomId);
|
|
131
|
+
if (room) room.leave(this.connectionId);
|
|
132
|
+
this.roomManager.removeIfEmpty(this.currentRoomId);
|
|
133
|
+
}
|
|
134
|
+
this.currentRoomId = roomId;
|
|
135
|
+
const room = this.roomManager.getOrCreate(roomId);
|
|
136
|
+
const handle: RoomConnectionHandle = {
|
|
137
|
+
connectionId: this.connectionId,
|
|
138
|
+
userId: this.userId,
|
|
139
|
+
name: this.userName,
|
|
140
|
+
email: this.userEmail,
|
|
141
|
+
provider: this.provider,
|
|
142
|
+
presence: {},
|
|
143
|
+
send: (m) => this.send(m),
|
|
144
|
+
};
|
|
145
|
+
await room.join(handle, presence);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case MSG_LEAVE_ROOM: {
|
|
149
|
+
const roomId = clientMsg.payload?.roomId ?? this.currentRoomId;
|
|
150
|
+
if (roomId && this.currentRoomId === roomId) {
|
|
151
|
+
const room = this.roomManager.get(roomId);
|
|
152
|
+
if (room) room.leave(this.connectionId);
|
|
153
|
+
this.roomManager.removeIfEmpty(roomId);
|
|
154
|
+
this.currentRoomId = null;
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case MSG_UPDATE_PRESENCE: {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
if (now - this.lastPresenceUpdate < this.presenceThrottleMs) return;
|
|
161
|
+
this.lastPresenceUpdate = now;
|
|
162
|
+
if (!this.currentRoomId) return;
|
|
163
|
+
const room = this.roomManager.get(this.currentRoomId);
|
|
164
|
+
if (room) room.updatePresence(this.connectionId, clientMsg.payload.presence as Presence);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case MSG_BROADCAST_EVENT: {
|
|
168
|
+
if (!this.currentRoomId) return;
|
|
169
|
+
const room = this.roomManager.get(this.currentRoomId);
|
|
170
|
+
if (room) {
|
|
171
|
+
room.broadcastEvent(
|
|
172
|
+
this.connectionId,
|
|
173
|
+
clientMsg.payload.event,
|
|
174
|
+
clientMsg.payload.payload,
|
|
175
|
+
this.userId
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case MSG_SEND_CHAT: {
|
|
181
|
+
if (!this.currentRoomId) return;
|
|
182
|
+
const room = this.roomManager.get(this.currentRoomId);
|
|
183
|
+
if (room) {
|
|
184
|
+
await room.sendChat(
|
|
185
|
+
this.connectionId,
|
|
186
|
+
clientMsg.payload.message,
|
|
187
|
+
clientMsg.payload.metadata,
|
|
188
|
+
this.userId
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private handleClose(): void {
|
|
197
|
+
this.closed = true;
|
|
198
|
+
if (this.currentRoomId) {
|
|
199
|
+
const room = this.roomManager.get(this.currentRoomId);
|
|
200
|
+
if (room) room.leave(this.connectionId);
|
|
201
|
+
this.roomManager.removeIfEmpty(this.currentRoomId);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @openlivesync/server - Node.js package for collaboration, presence, and chat.
|
|
3
|
+
* Export all public API and protocol types for use in a Node.js backend.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Server API
|
|
7
|
+
export {
|
|
8
|
+
createServer,
|
|
9
|
+
createWebSocketServer,
|
|
10
|
+
createWebSocketHandler,
|
|
11
|
+
type ServerOptions,
|
|
12
|
+
type WebSocketServerOptions,
|
|
13
|
+
type ChatOptions,
|
|
14
|
+
} from "./server.js";
|
|
15
|
+
|
|
16
|
+
// Protocol (for client compatibility and typing)
|
|
17
|
+
export type {
|
|
18
|
+
Presence,
|
|
19
|
+
UserInfo,
|
|
20
|
+
ClientMessage,
|
|
21
|
+
ServerMessage,
|
|
22
|
+
JoinRoomPayload,
|
|
23
|
+
LeaveRoomPayload,
|
|
24
|
+
UpdatePresencePayload,
|
|
25
|
+
BroadcastEventPayload,
|
|
26
|
+
SendChatPayload,
|
|
27
|
+
PresenceEntry,
|
|
28
|
+
RoomJoinedPayload,
|
|
29
|
+
PresenceUpdatedPayload,
|
|
30
|
+
BroadcastEventRelayPayload,
|
|
31
|
+
ChatMessagePayload,
|
|
32
|
+
StoredChatMessage,
|
|
33
|
+
ErrorPayload,
|
|
34
|
+
ChatMessageInput,
|
|
35
|
+
} from "./protocol.js";
|
|
36
|
+
export {
|
|
37
|
+
MSG_JOIN_ROOM,
|
|
38
|
+
MSG_LEAVE_ROOM,
|
|
39
|
+
MSG_UPDATE_PRESENCE,
|
|
40
|
+
MSG_BROADCAST_EVENT,
|
|
41
|
+
MSG_SEND_CHAT,
|
|
42
|
+
MSG_ROOM_JOINED,
|
|
43
|
+
MSG_PRESENCE_UPDATED,
|
|
44
|
+
MSG_BROADCAST_EVENT_RELAY,
|
|
45
|
+
MSG_CHAT_MESSAGE,
|
|
46
|
+
MSG_ERROR,
|
|
47
|
+
} from "./protocol.js";
|
|
48
|
+
|
|
49
|
+
// Auth (decode/verify access tokens; Google, Microsoft, custom OAuth)
|
|
50
|
+
export { decodeAccessToken, createTokenAuth } from "./auth/index.js";
|
|
51
|
+
export type {
|
|
52
|
+
AuthOptions,
|
|
53
|
+
AuthGoogleConfig,
|
|
54
|
+
AuthMicrosoftConfig,
|
|
55
|
+
AuthCustomConfig,
|
|
56
|
+
CreateTokenAuthOptions,
|
|
57
|
+
DecodedToken,
|
|
58
|
+
} from "./auth/index.js";
|
|
59
|
+
|
|
60
|
+
// Chat storage interface and in-memory (no extra deps)
|
|
61
|
+
export type { ChatStorage } from "./storage/chat-storage.js";
|
|
62
|
+
export { createInMemoryChatStorage } from "./storage/in-memory.js";
|
|
63
|
+
export type { InMemoryChatStorageOptions } from "./storage/in-memory.js";
|
|
64
|
+
|
|
65
|
+
// Optional DB adapters (require pg / mysql2 / better-sqlite3 to be installed)
|
|
66
|
+
export { createPostgresChatStorage } from "./storage/postgres.js";
|
|
67
|
+
export type {
|
|
68
|
+
PostgresChatStorageOptions,
|
|
69
|
+
PostgresConnectionConfig,
|
|
70
|
+
} from "./storage/postgres.js";
|
|
71
|
+
export { createMySQLChatStorage } from "./storage/mysql.js";
|
|
72
|
+
export type {
|
|
73
|
+
MySQLChatStorageOptions,
|
|
74
|
+
MySQLConnectionConfig,
|
|
75
|
+
} from "./storage/mysql.js";
|
|
76
|
+
export { createSQLiteChatStorage } from "./storage/sqlite.js";
|
|
77
|
+
export type {
|
|
78
|
+
SQLiteChatStorageOptions,
|
|
79
|
+
SQLiteConnectionConfig,
|
|
80
|
+
} from "./storage/sqlite.js";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
MSG_JOIN_ROOM,
|
|
4
|
+
MSG_LEAVE_ROOM,
|
|
5
|
+
MSG_UPDATE_PRESENCE,
|
|
6
|
+
MSG_BROADCAST_EVENT,
|
|
7
|
+
MSG_SEND_CHAT,
|
|
8
|
+
MSG_ROOM_JOINED,
|
|
9
|
+
MSG_PRESENCE_UPDATED,
|
|
10
|
+
MSG_CHAT_MESSAGE,
|
|
11
|
+
MSG_ERROR,
|
|
12
|
+
} from "./protocol.js";
|
|
13
|
+
|
|
14
|
+
describe("protocol constants", () => {
|
|
15
|
+
it("client message types are string constants", () => {
|
|
16
|
+
expect(MSG_JOIN_ROOM).toBe("join_room");
|
|
17
|
+
expect(MSG_LEAVE_ROOM).toBe("leave_room");
|
|
18
|
+
expect(MSG_UPDATE_PRESENCE).toBe("update_presence");
|
|
19
|
+
expect(MSG_BROADCAST_EVENT).toBe("broadcast_event");
|
|
20
|
+
expect(MSG_SEND_CHAT).toBe("send_chat");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("server message types are string constants", () => {
|
|
24
|
+
expect(MSG_ROOM_JOINED).toBe("room_joined");
|
|
25
|
+
expect(MSG_PRESENCE_UPDATED).toBe("presence_updated");
|
|
26
|
+
expect(MSG_CHAT_MESSAGE).toBe("chat_message");
|
|
27
|
+
expect(MSG_ERROR).toBe("error");
|
|
28
|
+
});
|
|
29
|
+
});
|