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