@whogoes/server 1.0.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.
Files changed (3) hide show
  1. package/README.md +66 -0
  2. package/dist/index.js +254 -0
  3. package/package.json +48 -0
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @whogoes/server
2
+
3
+ The backend service for Whogoes, built with Fastify, WebSocket, and Redis.
4
+
5
+ ## Architecture
6
+
7
+ - **Fastify**: Web framework and WebSocket support.
8
+ - **ioredis**: Redis client for Pub/Sub and state management.
9
+ - **WebSocket**: Real-time communication with clients.
10
+
11
+ ## Configuration
12
+
13
+ Environment variables can be set in a `.env` file or the system environment.
14
+
15
+ | Variable | Description | Default |
16
+ |Link |---|---| (sic)
17
+ | `PORT` | The port the server listens on | `3000` |
18
+ | `REDIS_URL` | Connection string for Redis | (local default) |
19
+ | `USE_MOCK_REDIS` | Set to `true` to use in-memory Redis | `false` |
20
+
21
+ ## Running
22
+
23
+ ### Development
24
+
25
+ Run the server with hot-reloading.
26
+
27
+ ```bash
28
+ npm run dev
29
+ ```
30
+
31
+ ### Mock Mode
32
+
33
+ Run without a real Redis instance (useful for local development).
34
+
35
+ ```bash
36
+ npm run dev:mock
37
+ ```
38
+
39
+ ### Production
40
+
41
+ Build and start the server.
42
+
43
+ ```bash
44
+ npm run build
45
+ npm run start
46
+ ```
47
+
48
+ ## API
49
+
50
+ ### WebSocket `/ws`
51
+
52
+ The primary endpoint for client connections. Clients should upgrade to this endpoint to establish a WebSocket connection.
53
+
54
+ **Query Parameters:**
55
+ - `token`: Authentication token (currently mocked/placeholder).
56
+ - `roomId`: The ID of the room to join.
57
+
58
+ ### Messages
59
+
60
+ The server handles the following message types:
61
+
62
+ - `ping` / `pong`: Heartbeat.
63
+ - `presence.update`: Updates a user's state.
64
+ - `presence.snapshot`: Sent to client on join with full room state.
65
+ - `presence.join`: Broadcast when a user joins.
66
+ - `presence.leave`: Broadcast when a user leaves.
package/dist/index.js ADDED
@@ -0,0 +1,254 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") {
10
+ for (let key of __getOwnPropNames(from))
11
+ if (!__hasOwnProp.call(to, key) && key !== except)
12
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
+ }
14
+ return to;
15
+ };
16
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
17
+ // If the importer is in node compatibility mode or this is not an ESM
18
+ // file that has been converted to a CommonJS file using a Babel-
19
+ // compatible transform (i.e. "__esModule" has not been set), then set
20
+ // "default" to the CommonJS "module.exports" for node compatibility.
21
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
22
+ mod
23
+ ));
24
+
25
+ // src/index.ts
26
+ var import_config = require("dotenv/config");
27
+
28
+ // src/app.ts
29
+ var import_fastify = __toESM(require("fastify"));
30
+ var import_websocket = __toESM(require("@fastify/websocket"));
31
+
32
+ // src/websocket.ts
33
+ var import_uuid = require("uuid");
34
+
35
+ // src/redis.ts
36
+ var import_ioredis = __toESM(require("ioredis"));
37
+ var import_ioredis_mock = __toESM(require("ioredis-mock"));
38
+ var redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
39
+ var useMock = process.env.USE_MOCK_REDIS === "true";
40
+ console.log(`Initializing Redis (Mock: ${useMock})`);
41
+ var redisClient;
42
+ var subRedisClient;
43
+ if (useMock) {
44
+ const sharedData = {};
45
+ redisClient = new import_ioredis_mock.default({ data: sharedData });
46
+ subRedisClient = new import_ioredis_mock.default({ data: sharedData });
47
+ } else {
48
+ redisClient = new import_ioredis.default(redisUrl);
49
+ subRedisClient = new import_ioredis.default(redisUrl);
50
+ redisClient.on("error", (err) => console.error("Redis Client Error", err));
51
+ subRedisClient.on("error", (err) => console.error("Redis Subscriber Error", err));
52
+ redisClient.on("connect", () => console.log("Redis connected"));
53
+ subRedisClient.on("connect", () => console.log("Redis subscriber connected"));
54
+ }
55
+ var redis = redisClient;
56
+ var subRedis = subRedisClient;
57
+
58
+ // src/roomManager.ts
59
+ var RoomManager = class {
60
+ constructor() {
61
+ this.subscriptionMap = /* @__PURE__ */ new Map();
62
+ subRedis.on("message", (channel, message) => {
63
+ this.handleMessage(channel, message);
64
+ });
65
+ }
66
+ handleMessage(channel, message) {
67
+ if (channel.startsWith("presence:room:")) {
68
+ const handlers = this.subscriptionMap.get(channel);
69
+ if (handlers) {
70
+ const parsed = JSON.parse(message);
71
+ handlers.forEach((handler) => handler(parsed));
72
+ }
73
+ }
74
+ }
75
+ getRoomKey(roomId) {
76
+ return `presence:room:${roomId}`;
77
+ }
78
+ getRoomChannel(roomId) {
79
+ return `presence:room:${roomId}:channel`;
80
+ }
81
+ async joinRoom(roomId, sessionId, payload) {
82
+ const key = this.getRoomKey(roomId);
83
+ await redis.hset(key, sessionId, JSON.stringify(payload));
84
+ await redis.expire(key, 300);
85
+ await this.broadcast(roomId, { type: "presence.join", sessionId, payload });
86
+ }
87
+ async leaveRoom(roomId, sessionId) {
88
+ const key = this.getRoomKey(roomId);
89
+ await redis.hdel(key, sessionId);
90
+ await this.broadcast(roomId, { type: "presence.leave", sessionId });
91
+ }
92
+ async updatePresence(roomId, sessionId, payload) {
93
+ const key = this.getRoomKey(roomId);
94
+ const currentData = await redis.hget(key, sessionId);
95
+ if (currentData) {
96
+ const current = JSON.parse(currentData);
97
+ const updated = { ...current, ...payload, lastActiveAt: Date.now() };
98
+ console.log("RoomManager.updatePresence", { roomId, sessionId, payload });
99
+ await redis.hset(key, sessionId, JSON.stringify(updated));
100
+ await this.broadcast(roomId, { type: "presence.update", sessionId, payload: updated });
101
+ }
102
+ }
103
+ async getSnapshot(roomId) {
104
+ const key = this.getRoomKey(roomId);
105
+ const data = await redis.hgetall(key);
106
+ return Object.values(data).map((s) => JSON.parse(s));
107
+ }
108
+ async subscribeToRoom(roomId, callback) {
109
+ const channel = this.getRoomChannel(roomId);
110
+ let handlers = this.subscriptionMap.get(channel);
111
+ if (!handlers) {
112
+ handlers = /* @__PURE__ */ new Set();
113
+ this.subscriptionMap.set(channel, handlers);
114
+ await subRedis.subscribe(channel);
115
+ }
116
+ handlers.add(callback);
117
+ return () => {
118
+ handlers?.delete(callback);
119
+ if (handlers?.size === 0) {
120
+ this.subscriptionMap.delete(channel);
121
+ subRedis.unsubscribe(channel);
122
+ }
123
+ };
124
+ }
125
+ async broadcast(roomId, message) {
126
+ const channel = this.getRoomChannel(roomId);
127
+ await redis.publish(channel, JSON.stringify(message));
128
+ }
129
+ };
130
+ var roomManager = new RoomManager();
131
+
132
+ // src/websocket.ts
133
+ var websocketHandler = async (connection, req) => {
134
+ const socket = connection && connection.socket ? connection.socket : connection;
135
+ const { roomId, token } = req.query;
136
+ if (!roomId || !token) {
137
+ socket.close(1008, "Missing roomId or token");
138
+ return;
139
+ }
140
+ const userId = "user-" + (0, import_uuid.v4)().slice(0, 8);
141
+ const sessionId = (0, import_uuid.v4)();
142
+ const initialPayload = {
143
+ sessionId,
144
+ user: { id: userId, name: "Anonymous" },
145
+ status: "active",
146
+ lastActiveAt: Date.now()
147
+ };
148
+ let unsubscribe;
149
+ try {
150
+ unsubscribe = await roomManager.subscribeToRoom(roomId, (message) => {
151
+ if (message.sessionId !== sessionId) {
152
+ socket.send(JSON.stringify(message));
153
+ }
154
+ });
155
+ const snapshot = await roomManager.getSnapshot(roomId);
156
+ socket.send(JSON.stringify({ type: "presence.snapshot", sessions: snapshot }));
157
+ await roomManager.joinRoom(roomId, sessionId, initialPayload);
158
+ socket.on("message", async (message) => {
159
+ console.log("WS message received raw:", message && message.toString && message.toString());
160
+ try {
161
+ const data = JSON.parse(message.toString());
162
+ switch (data.type) {
163
+ case "presence.update":
164
+ await roomManager.updatePresence(roomId, sessionId, data.payload);
165
+ break;
166
+ case "ping":
167
+ socket.send(JSON.stringify({ type: "pong" }));
168
+ await roomManager.updatePresence(roomId, sessionId, { lastActiveAt: Date.now() });
169
+ break;
170
+ }
171
+ } catch (e) {
172
+ console.error("Error handling message", e);
173
+ }
174
+ });
175
+ socket.on("close", async () => {
176
+ if (unsubscribe) unsubscribe();
177
+ await roomManager.leaveRoom(roomId, sessionId);
178
+ });
179
+ } catch (err) {
180
+ console.error("WebSocket connection error:", err);
181
+ try {
182
+ socket.close(1011, "Internal Server Error");
183
+ } catch (e) {
184
+ }
185
+ if (unsubscribe) unsubscribe();
186
+ }
187
+ };
188
+
189
+ // src/routes.ts
190
+ var routes = async (fastify) => {
191
+ fastify.get("/health", async () => {
192
+ return { status: "ok" };
193
+ });
194
+ fastify.get("/v1/rooms/:roomId/stats", async (request, reply) => {
195
+ const { roomId } = request.params;
196
+ const sessions = await roomManager.getSnapshot(roomId);
197
+ let lastActivity = 0;
198
+ if (sessions.length > 0) {
199
+ lastActivity = Math.max(...sessions.map((s) => s.lastActiveAt || 0));
200
+ }
201
+ return {
202
+ roomId,
203
+ activeSessions: sessions.length,
204
+ lastActivity: lastActivity > 0 ? new Date(lastActivity).toISOString() : null
205
+ };
206
+ });
207
+ fastify.get("/v1/rooms/:roomId/sessions", async (request, reply) => {
208
+ const { roomId } = request.params;
209
+ const sessions = await roomManager.getSnapshot(roomId);
210
+ return { sessions };
211
+ });
212
+ fastify.get("/v1/rooms/:roomId/sessions/:userId", async (request, reply) => {
213
+ const { roomId, userId } = request.params;
214
+ const sessions = await roomManager.getSnapshot(roomId);
215
+ const userSessions = sessions.filter((s) => s.user.id === userId);
216
+ return { sessions: userSessions };
217
+ });
218
+ };
219
+
220
+ // src/app.ts
221
+ var buildApp = async () => {
222
+ const app = (0, import_fastify.default)({
223
+ logger: true
224
+ });
225
+ await app.register(import_websocket.default);
226
+ app.register(async (fastify) => {
227
+ fastify.get("/ws", { websocket: true }, websocketHandler);
228
+ });
229
+ app.register(routes);
230
+ return app;
231
+ };
232
+
233
+ // src/index.ts
234
+ var start = async () => {
235
+ const app = await buildApp();
236
+ const port = process.env.PORT ? parseInt(process.env.PORT) : 3e3;
237
+ try {
238
+ await app.listen({ port, host: "0.0.0.0" });
239
+ console.log(`Server listening on port ${port}`);
240
+ } catch (err) {
241
+ app.log.error(err);
242
+ process.exit(1);
243
+ }
244
+ const close = async () => {
245
+ console.log("Shutting down...");
246
+ await app.close();
247
+ await redis.quit();
248
+ await subRedis.quit();
249
+ process.exit(0);
250
+ };
251
+ process.on("SIGINT", close);
252
+ process.on("SIGTERM", close);
253
+ };
254
+ start();
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@whogoes/server",
3
+ "version": "1.0.0",
4
+ "description": "Self-hostable real-time presence server — Fastify + WebSocket + Redis",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "whogoes-server": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "scripts": {
16
+ "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
17
+ "dev:mock": "USE_MOCK_REDIS=true ts-node-dev --respawn --transpile-only src/index.ts",
18
+ "build": "tsup src/index.ts --external @fastify/websocket --external fastify --external ioredis --external ioredis-mock --external uuid --external zod --external @whogoes/shared --external dotenv",
19
+ "start": "node dist/index.js"
20
+ },
21
+ "keywords": [
22
+ "presence",
23
+ "realtime",
24
+ "websocket",
25
+ "fastify",
26
+ "redis",
27
+ "collaboration",
28
+ "whogoes"
29
+ ],
30
+ "author": "whogoes contributors",
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@fastify/websocket": "^10.0.1",
34
+ "@whogoes/shared": "*",
35
+ "dotenv": "^16.4.5",
36
+ "fastify": "^4.26.2",
37
+ "ioredis": "^5.3.2",
38
+ "uuid": "^9.0.1",
39
+ "zod": "^3.22.4"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^20.11.24",
43
+ "@types/uuid": "^9.0.8",
44
+ "ioredis-mock": "^8.13.1",
45
+ "ts-node-dev": "^2.0.0",
46
+ "tsup": "^8.0.2"
47
+ }
48
+ }