@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
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol types for @openlivesync.
|
|
3
|
+
* Shared between server and client; server handles these message types.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Generic presence payload (cursor, name, color, etc.). Server does not interpret. */
|
|
7
|
+
export type Presence = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
/** User/session info attached by server from auth (optional). */
|
|
10
|
+
export interface UserInfo {
|
|
11
|
+
userId?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
email?: string;
|
|
14
|
+
provider?: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ----- Client → Server message types -----
|
|
19
|
+
|
|
20
|
+
export const MSG_JOIN_ROOM = "join_room";
|
|
21
|
+
export const MSG_LEAVE_ROOM = "leave_room";
|
|
22
|
+
export const MSG_UPDATE_PRESENCE = "update_presence";
|
|
23
|
+
export const MSG_BROADCAST_EVENT = "broadcast_event";
|
|
24
|
+
export const MSG_SEND_CHAT = "send_chat";
|
|
25
|
+
|
|
26
|
+
export interface JoinRoomPayload {
|
|
27
|
+
roomId: string;
|
|
28
|
+
presence?: Presence;
|
|
29
|
+
/** Optional OAuth/OpenID access token; server decodes to get name, email, provider. */
|
|
30
|
+
accessToken?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LeaveRoomPayload {
|
|
34
|
+
roomId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UpdatePresencePayload {
|
|
38
|
+
presence: Presence;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BroadcastEventPayload {
|
|
42
|
+
event: string;
|
|
43
|
+
payload?: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SendChatPayload {
|
|
47
|
+
message: string;
|
|
48
|
+
/** Optional application-defined metadata */
|
|
49
|
+
metadata?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ClientMessage =
|
|
53
|
+
| { type: typeof MSG_JOIN_ROOM; payload: JoinRoomPayload }
|
|
54
|
+
| { type: typeof MSG_LEAVE_ROOM; payload?: LeaveRoomPayload }
|
|
55
|
+
| { type: typeof MSG_UPDATE_PRESENCE; payload: UpdatePresencePayload }
|
|
56
|
+
| { type: typeof MSG_BROADCAST_EVENT; payload: BroadcastEventPayload }
|
|
57
|
+
| { type: typeof MSG_SEND_CHAT; payload: SendChatPayload };
|
|
58
|
+
|
|
59
|
+
// ----- Server → Client message types -----
|
|
60
|
+
|
|
61
|
+
export const MSG_ROOM_JOINED = "room_joined";
|
|
62
|
+
export const MSG_PRESENCE_UPDATED = "presence_updated";
|
|
63
|
+
export const MSG_BROADCAST_EVENT_RELAY = "broadcast_event";
|
|
64
|
+
export const MSG_CHAT_MESSAGE = "chat_message";
|
|
65
|
+
export const MSG_ERROR = "error";
|
|
66
|
+
|
|
67
|
+
export interface PresenceEntry {
|
|
68
|
+
connectionId: string;
|
|
69
|
+
userId?: string;
|
|
70
|
+
name?: string;
|
|
71
|
+
email?: string;
|
|
72
|
+
provider?: string;
|
|
73
|
+
presence: Presence;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RoomJoinedPayload {
|
|
77
|
+
roomId: string;
|
|
78
|
+
connectionId: string;
|
|
79
|
+
presence: Record<string, PresenceEntry>;
|
|
80
|
+
chatHistory?: StoredChatMessage[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PresenceUpdatedPayload {
|
|
84
|
+
roomId: string;
|
|
85
|
+
joined?: PresenceEntry[];
|
|
86
|
+
left?: string[];
|
|
87
|
+
updated?: PresenceEntry[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface BroadcastEventRelayPayload {
|
|
91
|
+
roomId: string;
|
|
92
|
+
connectionId: string;
|
|
93
|
+
userId?: string;
|
|
94
|
+
event: string;
|
|
95
|
+
payload?: unknown;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ChatMessagePayload {
|
|
99
|
+
roomId: string;
|
|
100
|
+
connectionId: string;
|
|
101
|
+
userId?: string;
|
|
102
|
+
message: string;
|
|
103
|
+
metadata?: Record<string, unknown>;
|
|
104
|
+
id?: string;
|
|
105
|
+
createdAt?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface StoredChatMessage {
|
|
109
|
+
id: string;
|
|
110
|
+
roomId: string;
|
|
111
|
+
connectionId: string;
|
|
112
|
+
userId?: string;
|
|
113
|
+
message: string;
|
|
114
|
+
metadata?: Record<string, unknown>;
|
|
115
|
+
createdAt: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ErrorPayload {
|
|
119
|
+
code: string;
|
|
120
|
+
message: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type ServerMessage =
|
|
124
|
+
| { type: typeof MSG_ROOM_JOINED; payload: RoomJoinedPayload }
|
|
125
|
+
| { type: typeof MSG_PRESENCE_UPDATED; payload: PresenceUpdatedPayload }
|
|
126
|
+
| { type: typeof MSG_BROADCAST_EVENT_RELAY; payload: BroadcastEventRelayPayload }
|
|
127
|
+
| { type: typeof MSG_CHAT_MESSAGE; payload: ChatMessagePayload }
|
|
128
|
+
| { type: typeof MSG_ERROR; payload: ErrorPayload };
|
|
129
|
+
|
|
130
|
+
/** Chat message as provided when appending (before storage adds id/createdAt). */
|
|
131
|
+
export interface ChatMessageInput {
|
|
132
|
+
roomId: string;
|
|
133
|
+
connectionId: string;
|
|
134
|
+
userId?: string;
|
|
135
|
+
message: string;
|
|
136
|
+
metadata?: Record<string, unknown>;
|
|
137
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages rooms: get-or-create by roomId.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Room } from "./room.js";
|
|
6
|
+
import type { ChatStorage } from "./storage/chat-storage.js";
|
|
7
|
+
|
|
8
|
+
export interface RoomManagerOptions {
|
|
9
|
+
chatStorage: ChatStorage;
|
|
10
|
+
historyLimit: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class RoomManager {
|
|
14
|
+
private readonly options: RoomManagerOptions;
|
|
15
|
+
private readonly rooms = new Map<string, Room>();
|
|
16
|
+
|
|
17
|
+
constructor(options: RoomManagerOptions) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getOrCreate(roomId: string): Room {
|
|
22
|
+
let room = this.rooms.get(roomId);
|
|
23
|
+
if (!room) {
|
|
24
|
+
room = new Room({
|
|
25
|
+
roomId,
|
|
26
|
+
chatStorage: this.options.chatStorage,
|
|
27
|
+
historyLimit: this.options.historyLimit,
|
|
28
|
+
});
|
|
29
|
+
this.rooms.set(roomId, room);
|
|
30
|
+
}
|
|
31
|
+
return room;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(roomId: string): Room | undefined {
|
|
35
|
+
return this.rooms.get(roomId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Remove room when empty (optional cleanup). */
|
|
39
|
+
removeIfEmpty(roomId: string): void {
|
|
40
|
+
const room = this.rooms.get(roomId);
|
|
41
|
+
if (room && room.connectionCount === 0) {
|
|
42
|
+
this.rooms.delete(roomId);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/room.test.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Room } from "./room.js";
|
|
3
|
+
import { RoomManager } from "./room-manager.js";
|
|
4
|
+
import { createInMemoryChatStorage } from "./storage/in-memory.js";
|
|
5
|
+
import { MSG_ROOM_JOINED, MSG_PRESENCE_UPDATED, MSG_CHAT_MESSAGE, MSG_BROADCAST_EVENT_RELAY } from "./protocol.js";
|
|
6
|
+
|
|
7
|
+
function mockHandle(
|
|
8
|
+
connectionId: string,
|
|
9
|
+
userId?: string,
|
|
10
|
+
sent: { value: unknown[] } = { value: [] }
|
|
11
|
+
): { handle: import("./room.js").RoomConnectionHandle; sent: unknown[] } {
|
|
12
|
+
const list = sent.value;
|
|
13
|
+
return {
|
|
14
|
+
handle: {
|
|
15
|
+
connectionId,
|
|
16
|
+
userId,
|
|
17
|
+
presence: {},
|
|
18
|
+
send: (msg: unknown) => list.push(msg),
|
|
19
|
+
},
|
|
20
|
+
sent: list,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("Room", () => {
|
|
25
|
+
it("join sends room_joined with presence and chat history", async () => {
|
|
26
|
+
const storage = createInMemoryChatStorage({ historyLimit: 10 });
|
|
27
|
+
const room = new Room({
|
|
28
|
+
roomId: "r1",
|
|
29
|
+
chatStorage: storage,
|
|
30
|
+
historyLimit: 10,
|
|
31
|
+
});
|
|
32
|
+
const { handle, sent } = mockHandle("c1", "u1");
|
|
33
|
+
await room.join(handle, { cursor: { x: 1, y: 2 } });
|
|
34
|
+
expect(sent).toHaveLength(1);
|
|
35
|
+
expect(sent[0]).toMatchObject({
|
|
36
|
+
type: MSG_ROOM_JOINED,
|
|
37
|
+
payload: {
|
|
38
|
+
roomId: "r1",
|
|
39
|
+
connectionId: "c1",
|
|
40
|
+
presence: { c1: { connectionId: "c1", userId: "u1", presence: { cursor: { x: 1, y: 2 } } } },
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
expect((sent[0] as { payload: { chatHistory?: unknown[] } }).payload.chatHistory).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("join sends room_joined with empty chatHistory when getHistory throws", async () => {
|
|
47
|
+
const failingStorage = {
|
|
48
|
+
append: async () => {},
|
|
49
|
+
getHistory: async () => {
|
|
50
|
+
throw new Error("storage unavailable");
|
|
51
|
+
},
|
|
52
|
+
} as import("./storage/chat-storage.js").ChatStorage;
|
|
53
|
+
const room = new Room({
|
|
54
|
+
roomId: "r1",
|
|
55
|
+
chatStorage: failingStorage,
|
|
56
|
+
historyLimit: 10,
|
|
57
|
+
});
|
|
58
|
+
const { handle, sent } = mockHandle("c1");
|
|
59
|
+
await room.join(handle);
|
|
60
|
+
expect(sent).toHaveLength(1);
|
|
61
|
+
expect((sent[0] as { payload: { chatHistory?: unknown[] } }).payload.chatHistory).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("leave broadcasts presence_updated with left", async () => {
|
|
65
|
+
const storage = createInMemoryChatStorage();
|
|
66
|
+
const room = new Room({ roomId: "r1", chatStorage: storage, historyLimit: 10 });
|
|
67
|
+
const { handle: h1, sent: s1 } = mockHandle("c1");
|
|
68
|
+
const { handle: h2, sent: s2 } = mockHandle("c2");
|
|
69
|
+
await room.join(h1);
|
|
70
|
+
await room.join(h2);
|
|
71
|
+
s1.length = 0;
|
|
72
|
+
s2.length = 0;
|
|
73
|
+
room.leave("c1");
|
|
74
|
+
expect(s1).toHaveLength(0);
|
|
75
|
+
expect(s2).toHaveLength(1);
|
|
76
|
+
expect(s2[0]).toMatchObject({
|
|
77
|
+
type: MSG_PRESENCE_UPDATED,
|
|
78
|
+
payload: { roomId: "r1", left: ["c1"] },
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("updatePresence broadcasts to others only", async () => {
|
|
83
|
+
const storage = createInMemoryChatStorage();
|
|
84
|
+
const room = new Room({ roomId: "r1", chatStorage: storage, historyLimit: 10 });
|
|
85
|
+
const { handle: h1, sent: s1 } = mockHandle("c1");
|
|
86
|
+
const { handle: h2, sent: s2 } = mockHandle("c2");
|
|
87
|
+
await room.join(h1);
|
|
88
|
+
await room.join(h2);
|
|
89
|
+
s1.length = 0;
|
|
90
|
+
s2.length = 0;
|
|
91
|
+
room.updatePresence("c1", { cursor: { x: 10 } });
|
|
92
|
+
expect(s1).toHaveLength(0);
|
|
93
|
+
expect(s2).toHaveLength(1);
|
|
94
|
+
expect(s2[0]).toMatchObject({
|
|
95
|
+
type: MSG_PRESENCE_UPDATED,
|
|
96
|
+
payload: { roomId: "r1", updated: [{ connectionId: "c1", presence: { cursor: { x: 10 } } }] },
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("updatePresence with unknown connectionId does nothing", async () => {
|
|
101
|
+
const storage = createInMemoryChatStorage();
|
|
102
|
+
const room = new Room({ roomId: "r1", chatStorage: storage, historyLimit: 10 });
|
|
103
|
+
const { handle, sent } = mockHandle("c1");
|
|
104
|
+
await room.join(handle);
|
|
105
|
+
sent.length = 0;
|
|
106
|
+
room.updatePresence("unknown-connection", { x: 1 });
|
|
107
|
+
expect(sent).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("broadcastEvent relays to others only", async () => {
|
|
111
|
+
const storage = createInMemoryChatStorage();
|
|
112
|
+
const room = new Room({ roomId: "r1", chatStorage: storage, historyLimit: 10 });
|
|
113
|
+
const { handle: h1, sent: s1 } = mockHandle("c1");
|
|
114
|
+
const { handle: h2, sent: s2 } = mockHandle("c2");
|
|
115
|
+
await room.join(h1);
|
|
116
|
+
await room.join(h2);
|
|
117
|
+
s1.length = 0;
|
|
118
|
+
s2.length = 0;
|
|
119
|
+
room.broadcastEvent("c1", "draw", { x: 1, y: 2 }, "u1");
|
|
120
|
+
expect(s1).toHaveLength(0);
|
|
121
|
+
expect(s2).toHaveLength(1);
|
|
122
|
+
expect(s2[0]).toMatchObject({
|
|
123
|
+
type: MSG_BROADCAST_EVENT_RELAY,
|
|
124
|
+
payload: { roomId: "r1", connectionId: "c1", userId: "u1", event: "draw", payload: { x: 1, y: 2 } },
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("sendChat appends to storage and broadcasts to all", async () => {
|
|
129
|
+
const storage = createInMemoryChatStorage();
|
|
130
|
+
const room = new Room({ roomId: "r1", chatStorage: storage, historyLimit: 10 });
|
|
131
|
+
const { handle: h1, sent: s1 } = mockHandle("c1", "u1");
|
|
132
|
+
const { handle: h2, sent: s2 } = mockHandle("c2");
|
|
133
|
+
await room.join(h1);
|
|
134
|
+
await room.join(h2);
|
|
135
|
+
s1.length = 0;
|
|
136
|
+
s2.length = 0;
|
|
137
|
+
await room.sendChat("c1", "hello", { replyTo: "x" }, "u1");
|
|
138
|
+
expect(s1).toHaveLength(1);
|
|
139
|
+
expect(s2).toHaveLength(1);
|
|
140
|
+
expect(s1[0]).toMatchObject({
|
|
141
|
+
type: MSG_CHAT_MESSAGE,
|
|
142
|
+
payload: { roomId: "r1", connectionId: "c1", userId: "u1", message: "hello", metadata: { replyTo: "x" } },
|
|
143
|
+
});
|
|
144
|
+
const history = await storage.getHistory("r1");
|
|
145
|
+
expect(history).toHaveLength(1);
|
|
146
|
+
expect(history[0].message).toBe("hello");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("RoomManager", () => {
|
|
151
|
+
it("getOrCreate returns same room for same id", () => {
|
|
152
|
+
const storage = createInMemoryChatStorage();
|
|
153
|
+
const manager = new RoomManager({ chatStorage: storage, historyLimit: 10 });
|
|
154
|
+
const a = manager.getOrCreate("r1");
|
|
155
|
+
const b = manager.getOrCreate("r1");
|
|
156
|
+
expect(a).toBe(b);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("get returns undefined for unknown room", () => {
|
|
160
|
+
const storage = createInMemoryChatStorage();
|
|
161
|
+
const manager = new RoomManager({ chatStorage: storage, historyLimit: 10 });
|
|
162
|
+
expect(manager.get("r1")).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("removeIfEmpty removes room when no connections", async () => {
|
|
166
|
+
const storage = createInMemoryChatStorage();
|
|
167
|
+
const manager = new RoomManager({ chatStorage: storage, historyLimit: 10 });
|
|
168
|
+
const room = manager.getOrCreate("r1");
|
|
169
|
+
const { handle } = mockHandle("c1");
|
|
170
|
+
await room.join(handle);
|
|
171
|
+
room.leave("c1");
|
|
172
|
+
manager.removeIfEmpty("r1");
|
|
173
|
+
expect(manager.get("r1")).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/room.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single room: connections, presence map, broadcast, and chat.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Presence,
|
|
7
|
+
PresenceEntry,
|
|
8
|
+
ServerMessage,
|
|
9
|
+
StoredChatMessage,
|
|
10
|
+
} from "./protocol.js";
|
|
11
|
+
import {
|
|
12
|
+
MSG_CHAT_MESSAGE,
|
|
13
|
+
MSG_PRESENCE_UPDATED,
|
|
14
|
+
MSG_ROOM_JOINED,
|
|
15
|
+
MSG_BROADCAST_EVENT_RELAY,
|
|
16
|
+
} from "./protocol.js";
|
|
17
|
+
import type { ChatStorage } from "./storage/chat-storage.js";
|
|
18
|
+
|
|
19
|
+
/** Handle the room uses to send messages to a connection. */
|
|
20
|
+
export interface RoomConnectionHandle {
|
|
21
|
+
connectionId: string;
|
|
22
|
+
userId?: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
email?: string;
|
|
25
|
+
provider?: string;
|
|
26
|
+
presence: Presence;
|
|
27
|
+
send(msg: ServerMessage): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RoomOptions {
|
|
31
|
+
roomId: string;
|
|
32
|
+
chatStorage: ChatStorage;
|
|
33
|
+
historyLimit: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class Room {
|
|
37
|
+
private readonly roomId: string;
|
|
38
|
+
private readonly chatStorage: ChatStorage;
|
|
39
|
+
private readonly historyLimit: number;
|
|
40
|
+
private readonly connections = new Map<string, RoomConnectionHandle>();
|
|
41
|
+
|
|
42
|
+
constructor(options: RoomOptions) {
|
|
43
|
+
this.roomId = options.roomId;
|
|
44
|
+
this.chatStorage = options.chatStorage;
|
|
45
|
+
this.historyLimit = options.historyLimit;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get connectionCount(): number {
|
|
49
|
+
return this.connections.size;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Add connection to room; send room_joined to connection and presence_updated (joined) to others. */
|
|
53
|
+
async join(
|
|
54
|
+
handle: RoomConnectionHandle,
|
|
55
|
+
initialPresence: Presence = {}
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const entry: RoomConnectionHandle = {
|
|
58
|
+
...handle,
|
|
59
|
+
presence: { ...initialPresence },
|
|
60
|
+
};
|
|
61
|
+
this.connections.set(handle.connectionId, entry);
|
|
62
|
+
|
|
63
|
+
const presenceMap: Record<string, PresenceEntry> = {};
|
|
64
|
+
for (const [, c] of this.connections) {
|
|
65
|
+
presenceMap[c.connectionId] = {
|
|
66
|
+
connectionId: c.connectionId,
|
|
67
|
+
userId: c.userId,
|
|
68
|
+
name: c.name,
|
|
69
|
+
email: c.email,
|
|
70
|
+
provider: c.provider,
|
|
71
|
+
presence: c.presence,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let chatHistory: StoredChatMessage[] | undefined;
|
|
76
|
+
try {
|
|
77
|
+
chatHistory = await this.chatStorage.getHistory(
|
|
78
|
+
this.roomId,
|
|
79
|
+
this.historyLimit
|
|
80
|
+
);
|
|
81
|
+
} catch {
|
|
82
|
+
chatHistory = [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
handle.send({
|
|
86
|
+
type: MSG_ROOM_JOINED,
|
|
87
|
+
payload: {
|
|
88
|
+
roomId: this.roomId,
|
|
89
|
+
connectionId: handle.connectionId,
|
|
90
|
+
presence: presenceMap,
|
|
91
|
+
chatHistory,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
this.broadcastExcept(handle.connectionId, {
|
|
96
|
+
type: MSG_PRESENCE_UPDATED,
|
|
97
|
+
payload: {
|
|
98
|
+
roomId: this.roomId,
|
|
99
|
+
joined: [
|
|
100
|
+
{
|
|
101
|
+
connectionId: handle.connectionId,
|
|
102
|
+
userId: handle.userId,
|
|
103
|
+
name: handle.name,
|
|
104
|
+
email: handle.email,
|
|
105
|
+
provider: handle.provider,
|
|
106
|
+
presence: entry.presence,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Remove connection and notify others. */
|
|
114
|
+
leave(connectionId: string): void {
|
|
115
|
+
this.connections.delete(connectionId);
|
|
116
|
+
this.broadcast({
|
|
117
|
+
type: MSG_PRESENCE_UPDATED,
|
|
118
|
+
payload: {
|
|
119
|
+
roomId: this.roomId,
|
|
120
|
+
left: [connectionId],
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Update presence for a connection and broadcast updated entry. */
|
|
126
|
+
updatePresence(connectionId: string, presence: Presence): void {
|
|
127
|
+
const conn = this.connections.get(connectionId);
|
|
128
|
+
if (!conn) return;
|
|
129
|
+
conn.presence = { ...presence };
|
|
130
|
+
this.broadcastExcept(connectionId, {
|
|
131
|
+
type: MSG_PRESENCE_UPDATED,
|
|
132
|
+
payload: {
|
|
133
|
+
roomId: this.roomId,
|
|
134
|
+
updated: [
|
|
135
|
+
{
|
|
136
|
+
connectionId: conn.connectionId,
|
|
137
|
+
userId: conn.userId,
|
|
138
|
+
name: conn.name,
|
|
139
|
+
email: conn.email,
|
|
140
|
+
provider: conn.provider,
|
|
141
|
+
presence: conn.presence,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Relay collaboration event to other clients in the room. */
|
|
149
|
+
broadcastEvent(
|
|
150
|
+
connectionId: string,
|
|
151
|
+
event: string,
|
|
152
|
+
payload: unknown,
|
|
153
|
+
userId?: string
|
|
154
|
+
): void {
|
|
155
|
+
this.broadcastExcept(connectionId, {
|
|
156
|
+
type: MSG_BROADCAST_EVENT_RELAY,
|
|
157
|
+
payload: {
|
|
158
|
+
roomId: this.roomId,
|
|
159
|
+
connectionId,
|
|
160
|
+
userId,
|
|
161
|
+
event,
|
|
162
|
+
payload,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Append chat message to storage and broadcast to all in room. */
|
|
168
|
+
async sendChat(
|
|
169
|
+
connectionId: string,
|
|
170
|
+
message: string,
|
|
171
|
+
metadata: Record<string, unknown> | undefined,
|
|
172
|
+
userId?: string
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
await this.chatStorage.append(this.roomId, {
|
|
175
|
+
roomId: this.roomId,
|
|
176
|
+
connectionId,
|
|
177
|
+
userId,
|
|
178
|
+
message,
|
|
179
|
+
metadata,
|
|
180
|
+
});
|
|
181
|
+
const payload = {
|
|
182
|
+
roomId: this.roomId,
|
|
183
|
+
connectionId,
|
|
184
|
+
userId,
|
|
185
|
+
message,
|
|
186
|
+
metadata,
|
|
187
|
+
};
|
|
188
|
+
this.broadcast({
|
|
189
|
+
type: MSG_CHAT_MESSAGE,
|
|
190
|
+
payload,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private broadcast(msg: ServerMessage): void {
|
|
195
|
+
for (const conn of this.connections.values()) {
|
|
196
|
+
conn.send(msg);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private broadcastExcept(exceptConnectionId: string, msg: ServerMessage): void {
|
|
201
|
+
for (const conn of this.connections.values()) {
|
|
202
|
+
if (conn.connectionId !== exceptConnectionId) {
|
|
203
|
+
conn.send(msg);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|