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