-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathserver.js
311 lines (272 loc) · 11.3 KB
/
server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
// Base Websocket name for our groups
const SOCKET_GROUP = 'watcher_';
// Set a default Momentum goal amount, could be 0
const DEFAULT_GOAL = 10;
// Setup a good generator for our session IDs
// Skip npm and dependencies and just copy the single magic line from https://www.npmjs.com/package/nanoid
// Note instead of a custom library we just drop - and _ as options and use "Z" in that case instead
const nanoid=(t=21)=>crypto.getRandomValues(new Uint8Array(t)).reduce(((t,e)=>t+=(e&=63)<36?e.toString(36):e<62?(e-26).toString(36).toUpperCase():"Z"),"");
const sessions = {
// Will have the format of a generated Session ID, with these properties:
// sessionId: { playerMomentum, playerGoal, opponentMomentum, opponentGoal, guests }
};
const DEFAULT_HOSTNAME = Bun.env.isProduction ? 'distant-adventures-app.onrender.com' : 'localhost';
const DEFAULT_PORT = 3000;
const server = Bun.serve({
port: DEFAULT_PORT,
async fetch(req, server) {
// Skip everything if we're just pinging
const urlObj = new URL(req.url);
if (urlObj.pathname === '/ping') {
return handlePingPong(req);
}
// Determine if we have an existing session
const { searchParams } = urlObj;
let sessionId = null;
if (searchParams && searchParams.get("id")) {
if (sessions[searchParams.get("id")]) {
sessionId = searchParams.get("id");
log("Existing sessionId=" + sessionId);
}
// If we don't have a match, maybe the user is refreshing a stale or bookmarked URL
// So let's give them the benefit of the doubt and try to create their session with the desired ID
else {
sessionId = makeNewSession(searchParams.get("id"));
}
}
// Create a new session
if (!sessionId) {
sessionId = makeNewSession();
}
// Handle our incoming request depending on the path
switch (urlObj.pathname) {
case '/':
// Read our HTML page file
let toReturn = await Bun.file('./main.html').text();
// Log how many sessions we have currently
log("Session count " + Object.keys(sessions).length);
// Replace our various data points in the page with our current session data
toReturn = toReturn.replaceAll('"${DO_SERVER_PING}"', Bun.env.isProduction);
toReturn = toReturn.replaceAll('"${HOSTNAME}"', '"' + DEFAULT_HOSTNAME + '"');
toReturn = toReturn.replaceAll('"${PORT}"', '"' + DEFAULT_PORT + '"');
toReturn = toReturn.replaceAll('"${SESSION_ID}"', '"' + sessionId + '"');
toReturn = toReturn.replaceAll('${PLAYER_GOAL}', sessions[sessionId].playerGoal);
toReturn = toReturn.replaceAll('${PLAYER_MOMENTUM}', sessions[sessionId].playerMomentum);
toReturn = toReturn.replaceAll('${OPPONENT_GOAL}', sessions[sessionId].opponentGoal);
toReturn = toReturn.replaceAll('${OPPONENT_MOMENTUM}', sessions[sessionId].opponentMomentum);
return new Response(toReturn, { headers: { 'Content-Length': toReturn.length, 'Content-Type': 'text/html;charset=utf-8' }});
case '/ws':
if (server.upgrade(req)) {
return; // Return nothing if successful
}
return new Response("Websocket upgrade failed", { status: 500 });
case '/momentum':
return handleMomentumPost(req);
case '/goal':
return handleGoalPost(req);
case '/state':
return handleStateGet(req, sessionId);
default:
return new Response("Not found", { status: 404 });
}
},
websocket: {
idleTimeout: 960, // Maximum allowed by Bun = 16 minutes, as of their v1.1
sendPings: true, // Send ping-pong over websocket automatically to keep clients alive
message(ws, content) {
if (content) {
try{
const parsedContent = JSON.parse(content);
if (parsedContent.sessionId && parsedContent.type) {
if (parsedContent.type === 'subscribe') {
ws.subscribe(SOCKET_GROUP + parsedContent.sessionId);
// Maintain our guests count
sessions[parsedContent.sessionId].guests++;
}
else if (parsedContent.type === 'unsubscribe') {
ws.unsubscribe(SOCKET_GROUP + parsedContent.sessionId);
// Loose way to keep the session list from getting out of control over time
// Reduce guest count, and if at or below 0 (...never know) clear the session
// If someone has the link and refreshes their browser, it'll be recreated anyway (although will lose state)
sessions[parsedContent.sessionId].guests--;
if (sessions[parsedContent.sessionId].guests <= 0) {
// We wait a bit before deleting the session, just in case the lone user was refreshing
setTimeout(() => {
try{
// Safely check all our data, a lot can happen in a few seconds haha
if (sessions && parsedContent.sessionId &&
sessions[parsedContent.sessionId] &&
sessions[parsedContent.sessionId].guests <= 0) {
delete sessions[parsedContent.sessionId];
}
}catch (ignored) { }
}, 30000);
}
}
}
}catch (ignored) { }
}
},
// Don't do anything with open/close/drain, so leave undeclared
},
});
async function handleMomentumPost(req) {
try{
const body = await req.json();
log("Momentum POST in", body);
// Determine if we have a valid Session ID to work with
const currentSessionId = body.sessionId;
if (!currentSessionId) {
return new Response("Session ID is required", { status: 400 });
}
const currentSession = sessions[currentSessionId];
if (!currentSession) {
return new Response("No Session was found", { status: 400 });
}
// Set our other flags: isPlayer, isSet, and momentum
let isPlayer = body.isPlayer ? true : false;
let isSet = body.isSet ? true : false;
let momentum = 0;
if (typeof body.momentum === 'number') {
momentum = body.momentum;
// Cap our Momentum to stop from getting too silly
if (momentum > 100) {
momentum = 100;
}
}
log("Momentum POST params sessionId=" + currentSessionId + " isPlayer=" + isPlayer + " isSet=" + isSet + " momentum=" + momentum);
// We're either setting the Momentum or just add/subtract based on the current
if (isSet) {
if (isPlayer) {
currentSession.playerMomentum = momentum;
}
else {
currentSession.opponentMomentum = momentum;
}
}
else {
if (isPlayer) {
currentSession.playerMomentum += momentum;
}
else {
currentSession.opponentMomentum += momentum;
}
}
// Minimum the Momentum
if (currentSession.playerMomentum < 0) { currentSession.playerMomentum = 0; }
if (currentSession.opponentMomentum < 0) { currentSession.opponentMomentum = 0; }
const toSend = {
isPlayer: isPlayer,
newMomentum: isPlayer ? currentSession.playerMomentum : currentSession.opponentMomentum
};
log("Momentum POST out", toSend);
server.publish(SOCKET_GROUP + currentSessionId, JSON.stringify(toSend));
return new Response(toSend);
}catch (err) {
log("Momentum POST failed", err);
}
return new Response(JSON.stringify({}), { status: 500 });
}
async function handleGoalPost(req) {
try{
const body = await req.json();
log("Goal POST in", body);
// Determine if we have a valid Session ID to work with
const currentSessionId = body.sessionId;
if (!currentSessionId) {
return new Response("Session ID is required", { status: 400 });
}
const currentSession = sessions[currentSessionId];
if (!currentSession) {
return new Response("No Session was found", { status: 400 });
}
// Set our other flags: isPlayer and goal
let isPlayer = body.isPlayer ? true : false;
let goal = 0;
if (typeof body.goal === 'number') {
goal = body.goal;
}
// Minimum the goal
if (goal < 0) { goal = 0; }
// Maximum the goal
if (goal > 1000) { goal = 1000; }
log("Goal POST params", currentSessionId, isPlayer, goal);
if (isPlayer) {
currentSession.playerGoal = goal;
}
else {
currentSession.opponentGoal = goal;
}
const toSend = {
isPlayer: isPlayer,
newGoal: isPlayer ? currentSession.playerGoal : currentSession.opponentGoal
};
log("Goal POST out", toSend);
server.publish(SOCKET_GROUP + currentSessionId, JSON.stringify(toSend));
return new Response(toSend);
}catch (err) {
log("Goal POST failed", err);
}
return new Response(JSON.stringify({}), { status: 500 });
}
async function handleStateGet(req, sessionId) {
try{
let workingSession = sessions[sessionId];
if (workingSession) {
return new Response(JSON.stringify({
playerGoal: workingSession.playerGoal,
playerMomentum: workingSession.playerMomentum,
opponentGoal: workingSession.opponentGoal,
opponentMomentum: workingSession.opponentMomentum
}));
}
}catch (err) {
log("State GET failed", err);
}
return new Response(JSON.stringify({}), { status: 404 });
}
function handlePingPong(req) {
// Yes, we're alive
return new Response("hey");
}
function makeNewSession(sessionId) {
if (!sessionId) {
sessionId = generateSessionId();
log("Make new sessionId=" + sessionId);
}
else {
log("Request non-existent, regenerating for sessionId=" + sessionId);
}
sessions[sessionId] = {
playerMomentum: 0,
playerGoal: DEFAULT_GOAL,
opponentMomentum: 0,
opponentGoal: DEFAULT_GOAL,
guests: 0
}
return sessionId;
}
function generateSessionId(tryAttempt) {
// Note uppercasing makes the session way easier to convey to friends, even if we marginally increase our collision odds after a million sessions
const newId = nanoid(4).toUpperCase();
// For collision matching we're waaaay overdoing it, as just a single regenerate will cover us for 100k+ sessions
// Which would be amazing if the game and app got that popular, haha
// But either way, may as well do it right, up to a cap of retries. Should be good for a million sessions or so
if (sessions[newId]) {
if (typeof tryAttempt !== 'number') {
tryAttempt = 0;
}
else if (tryAttempt > 100) {
log("Even after 100 tries of regenerating, we made a duplicate session ID. Current session count is " + Object.keys(sessions).length);
// In this super duper rare case, just throw in another digit
return nanoid(5).toUpperCase();
}
tryAttempt++;
return generateSessionId(tryAttempt);
}
return newId;
}
function log(message, ...extra) {
console.log(new Date().toLocaleString() + " - " + message, extra && extra.length > 0 ? extra : '');
}
log("Bun v" + Bun.version + ": Hit me with some Momentum on port " + DEFAULT_PORT + "!\n");