@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.
- package/README.md +66 -0
- package/dist/index.js +254 -0
- 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
|
+
}
|