A Typescript server emulator for Box Critters, a defunct virtual world.
1import { serve } from "https://deno.land/std@0.162.0/http/server.ts";
2import { contentType } from "https://deno.land/std@0.224.0/media_types/mod.ts";
3import {
4 dirname,
5 fromFileUrl,
6 join,
7 normalize,
8} from "https://deno.land/std@0.224.0/path/mod.ts";
9import { exists } from "jsr:@std/fs/exists";
10
11import { io } from "@/socket/index.ts";
12import * as world from "@/constants/world.ts";
13import { getAccount } from "@/utils.ts";
14import * as schemas from "@/schema.ts";
15import * as utils from "@/utils.ts";
16import parties from "@/constants/parties.json" with { type: "json" };
17import { extname } from "https://deno.land/std@0.212.0/path/extname.ts";
18import { parseArgs } from "jsr:@std/cli/parse-args";
19
20const EXECUTABLE = Deno.env.get("EXECUTABLE") == "true";
21const BASE_DIR = EXECUTABLE
22 ? dirname(Deno.execPath())
23 : dirname(dirname(fromFileUrl(Deno.mainModule)));
24const PUBLIC_DIR = join(BASE_DIR, "public");
25
26if (!EXECUTABLE) {
27 if (!await exists("./public") || !await exists(".env")) {
28 console.error("Missing files. Make sure you have `public/` and `.env`");
29 Deno.exit();
30 }
31}
32
33async function serveStatic(req: Request): Promise<Response> {
34 const url = new URL(req.url);
35 let pathname = url.pathname;
36 pathname = decodeURIComponent(pathname);
37 pathname = pathname.endsWith("/") ? pathname + "index.html" : pathname;
38
39 const fsPath = normalize(join(PUBLIC_DIR, pathname));
40
41 // Prevent directory traversal
42 if (!fsPath.startsWith(PUBLIC_DIR)) {
43 return new Response("Forbidden", { status: 403 });
44 }
45
46 try {
47 const file = await Deno.readFile(fsPath);
48 const mime = contentType(extname(fsPath)) || "application/octet-stream";
49 return new Response(file, {
50 headers: { "Content-Type": mime },
51 });
52 } catch {
53 return new Response("Not Found", { status: 404 });
54 }
55}
56
57async function handler(
58 req: Request,
59 connInfo: Deno.ServeHandlerInfo,
60): Promise<Response> {
61 const url = new URL(req.url);
62 const pathname = url.pathname;
63
64 if (req.headers.get("upgrade") === "websocket") {
65 //@ts-ignore: The websocket successfully upgrades
66 return io.handler()(req, connInfo);
67 }
68
69 if (req.method == "POST" && pathname == "/api/client/login") {
70 try {
71 const body = await req.json();
72 const parsed = schemas.login.safeParse(body);
73 if (!parsed.success) {
74 return Response.json({
75 success: false,
76 message: "Validation failure",
77 error: parsed.error,
78 }, { status: 400 });
79 }
80
81 const data = parsed.data;
82 const _players = Object.values(world.players);
83 const nameInUse = _players.find((p) => p.n === data.nickname) ||
84 world.queue.includes(data.nickname);
85
86 if (nameInUse) {
87 return Response.json({
88 success: false,
89 message: "There is already a player with this nickname online.",
90 });
91 }
92
93 const JWT_CONTENT = {
94 playerId: crypto.randomUUID(),
95 ...data,
96 };
97
98 const JWT_TOKEN = Deno.env.get("JWT_TOKEN");
99 if (!JWT_TOKEN) {
100 return new Response("JWT_TOKEN not set in env", { status: 500 });
101 }
102
103 const token = await utils.createJWT(JWT_CONTENT);
104
105 world.queue.push(data.nickname);
106
107 return Response.json({
108 success: true,
109 playerId: JWT_CONTENT.playerId,
110 token,
111 });
112 } catch {
113 return Response.json({
114 success: false,
115 message: "Bad request",
116 }, { status: 400 });
117 }
118 }
119
120 if (req.method == "GET") {
121 switch (pathname) {
122 case "/api/server/players": {
123 return Response.json({ players: world.players });
124 }
125
126 case "/api/server/rooms": {
127 return Response.json(world.rooms);
128 }
129
130 case "/api/server/persistence": {
131 const account = await getAccount();
132 return Response.json({
133 success: true,
134 data: account,
135 });
136 }
137
138 case "/api/client/rooms": {
139 const url = new URL(req.url);
140 let partyId = url.searchParams.get("partyId") || "default";
141 const debug = url.searchParams.has("debug");
142
143 if (
144 [
145 "today2019",
146 "today2020",
147 "today2021",
148 ].includes(partyId)
149 ) {
150 partyId = utils.getCurrentEvent(
151 parseInt(partyId.replace("today", "")),
152 );
153 }
154
155 if (!Object.keys(parties).includes(partyId)) {
156 return Response.json({
157 success: false,
158 message: "Invalid partyId hash provided.",
159 });
160 }
161
162 let missing = 0;
163 const roomResponse = Object.keys(world.rooms).reduce(
164 (res, roomId) => {
165 const room = world.rooms[roomId];
166
167 if (room[partyId]) {
168 if (
169 !room[partyId].partyExclusive ||
170 room[partyId]?.partyExclusive?.includes(partyId)
171 ) {
172 res.push(room[partyId]);
173 } else {
174 missing++;
175 }
176 } else {
177 if (
178 !room.default.partyExclusive ||
179 room.default.partyExclusive.includes(partyId)
180 ) {
181 res.push(room.default);
182 } else {
183 missing++;
184 }
185 }
186
187 return res;
188 },
189 [] as Array<typeof world.rooms[string]["default"]>,
190 );
191
192 if (missing === Object.keys(world.rooms).length) {
193 return Response.json({
194 success: false,
195 message:
196 "No rooms were fetched while indexxing using the specified partyId hash.",
197 });
198 }
199
200 const partyIds = Object.keys(parties);
201 if (debug) {
202 const roomNames = roomResponse.map((room) => room.name);
203 return Response.json({
204 parties: partyIds,
205 data: roomNames,
206 });
207 }
208
209 return Response.json({
210 parties: partyIds,
211 data: roomResponse,
212 });
213 }
214
215 default: {
216 return serveStatic(req);
217 }
218 }
219 }
220
221 return new Response("Not Found", { status: 404 });
222}
223
224const args = parseArgs(Deno.args, {
225 string: ["port"],
226 default: {
227 port: "3257",
228 },
229});
230
231if (isNaN(Number(args.port))) {
232 console.log("Port provided is not valid.");
233 Deno.exit();
234}
235
236//@ts-ignore: Type issues occuring from upgrading websocket requests to Socket.io
237await serve(handler, { port: args.port });