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 });