From cfbcaa4851268f503f190bb7557c2700281245c2 Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 19 Apr 2025 11:58:49 -0400 Subject: [PATCH] first commit --- .editorconfig | 12 ++ .env.example | 6 + .forgejo/workflows/biomejs.yml | 24 ++++ .gitattributes | 1 + .gitignore | 3 + LICENSE | 21 +++ README.md | 91 ++++++++++++ biome.json | 35 +++++ config/environment.ts | 38 +++++ package.json | 24 ++++ public/assets/favicon.ico | Bin 0 -> 15406 bytes src/helpers/badges.ts | 111 ++++++++++++++ src/helpers/char.ts | 19 +++ src/helpers/logger.ts | 205 ++++++++++++++++++++++++++ src/index.ts | 12 ++ src/routes/[id].ts | 96 +++++++++++++ src/routes/index.ts | 22 +++ src/server.ts | 254 +++++++++++++++++++++++++++++++++ src/websocket.ts | 30 ++++ tsconfig.json | 33 +++++ types/badge.d.ts | 11 ++ types/bun.d.ts | 14 ++ types/config.d.ts | 10 ++ types/logger.d.ts | 9 ++ types/routes.d.ts | 15 ++ 25 files changed, 1096 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .forgejo/workflows/biomejs.yml create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 biome.json create mode 100644 config/environment.ts create mode 100644 package.json create mode 100644 public/assets/favicon.ico create mode 100644 src/helpers/badges.ts create mode 100644 src/helpers/char.ts create mode 100644 src/helpers/logger.ts create mode 100644 src/index.ts create mode 100644 src/routes/[id].ts create mode 100644 src/routes/index.ts create mode 100644 src/server.ts create mode 100644 src/websocket.ts create mode 100644 tsconfig.json create mode 100644 types/badge.d.ts create mode 100644 types/bun.d.ts create mode 100644 types/config.d.ts create mode 100644 types/logger.d.ts create mode 100644 types/routes.d.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..980ef21 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..21aae90 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# NODE_ENV=development +HOST=0.0.0.0 +PORT=8080 + +REDIS_URL=redis://username:password@localhost:6379 +REDIS_TTL=3600 # seconds diff --git a/.forgejo/workflows/biomejs.yml b/.forgejo/workflows/biomejs.yml new file mode 100644 index 0000000..15c990c --- /dev/null +++ b/.forgejo/workflows/biomejs.yml @@ -0,0 +1,24 @@ +name: Code quality checks + +on: + push: + pull_request: + +jobs: + biome: + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Bun + run: | + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="$HOME/.bun" + echo "$BUN_INSTALL/bin" >> $GITHUB_PATH + + - name: Install Dependencies + run: bun install + + - name: Run Biome with verbose output + run: bunx biome ci . --verbose diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d23d9c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules +bun.lock +.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb5f6af --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab230e9 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Badge Aggregator API + +A fast Discord badge aggregation API built with [Bun](https://bun.sh) and Redis caching. + +## Features + +- Aggregates custom badge data from multiple sources (e.g. Vencord, Nekocord, Equicord, etc.) +- Optional caching via Redis (1 hour per user-service combo) +- Supports query options for service filtering, separated output, and cache bypass +- Written in TypeScript with formatting and linting using [BiomeJS](https://biomejs.dev) + +## Requirements + +- [Bun](https://bun.sh) (v1.2.9+) +- Redis instance, i suggest [Dragonfly](https://www.dragonflydb.io/) + +## Environment + +Copy the `.env.example` file in the root: + +```bash +cp .env.example .env +``` + +Then edit the `.env` file as needed: + +```env +# NODE_ENV is optional and can be used for conditional logic +NODE_ENV=development + +# The server will bind to this host and port +HOST=0.0.0.0 +PORT=8080 + +# Redis connection URL, password isn't required +REDIS_URL=redis://username:password@localhost:6379 + +# Value is in seconds +REDIS_TTL=3600 +``` + +## Endpoint + +```http +GET /:userId +``` + +### Path Parameters + +| Name | Description | +|---------|--------------------------| +| userId | Discord User ID to query | + +### Query Parameters + +| Name | Description | +|--------------|--------------------------------------------------------------------------| +| `services` | A comma or space separated list of services to fetch badges from | +| `cache` | Set to `true` or `false` (default: `true`). `false` bypasses Redis | +| `seperated` | Set to `true` to return results grouped by service, else merged array | + +### Supported Services + +- Vencord +- Equicord +- Nekocord +- ReviewDb + +### Example + +```http +GET /209830981060788225?seperated=true&cache=true&services=equicord +``` + +## Development + +Run formatting and linting with BiomeJS: + +```bash +bun run lint +bun run lint:fix +``` + +## Start the Server + +```bash +bun run start +``` + +## License +[MIT](LICENSE) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..921a7a5 --- /dev/null +++ b/biome.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": true, + "ignore": [] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineEnding": "lf" + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "indentStyle": "tab", + "lineEnding": "lf", + "jsxQuoteStyle": "double", + "semicolons": "always" + } + } +} diff --git a/config/environment.ts b/config/environment.ts new file mode 100644 index 0000000..03a3107 --- /dev/null +++ b/config/environment.ts @@ -0,0 +1,38 @@ +export const environment: Environment = { + port: Number.parseInt(process.env.PORT || "8080", 10), + host: process.env.HOST || "0.0.0.0", + development: + process.env.NODE_ENV === "development" || process.argv.includes("--dev"), +}; + +export const redisTtl: number = process.env.REDIS_TTL + ? Number.parseInt(process.env.REDIS_TTL, 10) + : 60 * 60 * 1; // 1 hour + +// not sure the point ? +// function getClientModBadgesUrl(userId: string): string { +// return `https://cdn.jsdelivr.net/gh/Equicord/ClientModBadges-API@main/users/${userId}.json`; +// } + +export const badgeServices: badgeURLMap[] = [ + { + service: "Vencord", + url: "https://badges.vencord.dev/badges.json", + }, + { + service: "Equicord", // Ekwekord ! WOOP + url: "https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/badges.json", + }, + { + service: "Nekocord", + url: "https://nekocord.dev/assets/badges.json", + }, + { + service: "ReviewDb", + url: "https://manti.vendicated.dev/api/reviewdb/badges", + }, + // { + // service: "ClientMods", + // url: getClientModBadgesUrl, + // } +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..db5e8a9 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "bun_frontend_template", + "module": "src/index.ts", + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun run --hot src/index.ts --dev", + "lint": "bunx biome check", + "lint:fix": "bunx biome check --fix", + "cleanup": "rm -rf logs node_modules bun.lockdb" + }, + "devDependencies": { + "@types/bun": "^1.2.9", + "@types/ejs": "^3.1.5", + "globals": "^16.0.0", + "@biomejs/biome": "^1.9.4" + }, + "peerDependencies": { + "typescript": "^5.8.3" + }, + "dependencies": { + "ejs": "^3.1.10" + } +} diff --git a/public/assets/favicon.ico b/public/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..69ec50db0ae55e9b54fd9fbf88c34bb1c420fb63 GIT binary patch literal 15406 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 { + const { nocache = false, separated = false } = options ?? {}; + const results: Record = {}; + + await Promise.all( + services.map(async (service) => { + const entry = badgeServices.find( + (s) => s.service.toLowerCase() === service.toLowerCase(), + ); + if (!entry) return; + + const serviceKey = service.toLowerCase(); + const cacheKey = `badges:${serviceKey}:${userId}`; + + if (!nocache) { + const cached = await redis.get(cacheKey); + if (cached) { + try { + const parsed: Badge[] = JSON.parse(cached); + results[serviceKey] = parsed; + return; + } catch { + // corrupted cache, proceed with fetch :p + } + } + } + + let url: string; + if (typeof entry.url === "function") { + url = entry.url(userId); + } else { + url = entry.url; + } + + try { + const res = await fetch(url); + if (!res.ok) return; + const data = await res.json(); + + const result: Badge[] = []; + + switch (serviceKey) { + case "vencord": + case "equicord": { + const userBadges = data[userId]; + if (Array.isArray(userBadges)) { + for (const b of userBadges) { + result.push({ + tooltip: b.tooltip, + badge: b.badge, + }); + } + } + break; + } + + case "nekocord": { + const userBadgeIds = data.users?.[userId]?.badges; + if (Array.isArray(userBadgeIds)) { + for (const id of userBadgeIds) { + const badgeInfo = data.badges?.[id]; + if (badgeInfo) { + result.push({ + tooltip: badgeInfo.name, + badge: badgeInfo.image, + }); + } + } + } + break; + } + + case "reviewdb": { + for (const b of data) { + if (b.discordID === userId) { + result.push({ + tooltip: b.name, + badge: b.icon, + }); + } + } + break; + } + } + + if (result.length > 0) { + results[serviceKey] = result; + if (!nocache) { + await redis.set(cacheKey, JSON.stringify(result)); + await redis.expire(cacheKey, redisTtl); + } + } + } catch (_) {} + }), + ); + + if (separated) return results; + + const combined: Badge[] = []; + for (const group of Object.values(results)) { + combined.push(...group); + } + return combined; +} diff --git a/src/helpers/char.ts b/src/helpers/char.ts new file mode 100644 index 0000000..c885429 --- /dev/null +++ b/src/helpers/char.ts @@ -0,0 +1,19 @@ +export function timestampToReadable(timestamp?: number): string { + const date: Date = + timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date(); + if (Number.isNaN(date.getTime())) return "Invalid Date"; + return date.toISOString().replace("T", " ").replace("Z", ""); +} + +export function validateID(id: string): boolean { + if (!id) return false; + + return /^\d{17,20}$/.test(id.trim()); +} + +export function parseServices(input: string): string[] { + return input + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter(Boolean); +} diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts new file mode 100644 index 0000000..4cbb12b --- /dev/null +++ b/src/helpers/logger.ts @@ -0,0 +1,205 @@ +import type { Stats } from "node:fs"; +import { + type WriteStream, + createWriteStream, + existsSync, + mkdirSync, + statSync, +} from "node:fs"; +import { EOL } from "node:os"; +import { basename, join } from "node:path"; +import { environment } from "@config/environment"; +import { timestampToReadable } from "@helpers/char"; + +class Logger { + private static instance: Logger; + private static log: string = join(__dirname, "../../logs"); + + public static getInstance(): Logger { + if (!Logger.instance) { + Logger.instance = new Logger(); + } + + return Logger.instance; + } + + private writeToLog(logMessage: string): void { + if (environment.development) return; + + const date: Date = new Date(); + const logDir: string = Logger.log; + const logFile: string = join( + logDir, + `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`, + ); + + if (!existsSync(logDir)) { + mkdirSync(logDir, { recursive: true }); + } + + let addSeparator = false; + + if (existsSync(logFile)) { + const fileStats: Stats = statSync(logFile); + if (fileStats.size > 0) { + const lastModified: Date = new Date(fileStats.mtime); + if ( + lastModified.getFullYear() === date.getFullYear() && + lastModified.getMonth() === date.getMonth() && + lastModified.getDate() === date.getDate() && + lastModified.getHours() !== date.getHours() + ) { + addSeparator = true; + } + } + } + + const stream: WriteStream = createWriteStream(logFile, { flags: "a" }); + + if (addSeparator) { + stream.write(`${EOL}${date.toISOString()}${EOL}`); + } + + stream.write(`${logMessage}${EOL}`); + stream.close(); + } + + private extractFileName(stack: string): string { + const stackLines: string[] = stack.split("\n"); + let callerFile = ""; + + for (let i = 2; i < stackLines.length; i++) { + const line: string = stackLines[i].trim(); + if (line && !line.includes("Logger.") && line.includes("(")) { + callerFile = line.split("(")[1]?.split(")")[0] || ""; + break; + } + } + + return basename(callerFile); + } + + private getCallerInfo(stack: unknown): { + filename: string; + timestamp: string; + } { + const filename: string = + typeof stack === "string" ? this.extractFileName(stack) : "unknown"; + + const readableTimestamp: string = timestampToReadable(); + + return { filename, timestamp: readableTimestamp }; + } + + public info(message: string | string[], breakLine = false): void { + const stack: string = new Error().stack || ""; + const { filename, timestamp } = this.getCallerInfo(stack); + + const joinedMessage: string = Array.isArray(message) + ? message.join(" ") + : message; + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: "[INFO]", color: "32" }, + filename: { value: `(${filename})`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog(`${timestamp} [INFO] (${filename}) ${joinedMessage}`); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public warn(message: string | string[], breakLine = false): void { + const stack: string = new Error().stack || ""; + const { filename, timestamp } = this.getCallerInfo(stack); + + const joinedMessage: string = Array.isArray(message) + ? message.join(" ") + : message; + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: "[WARN]", color: "33" }, + filename: { value: `(${filename})`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog(`${timestamp} [WARN] (${filename}) ${joinedMessage}`); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public error( + message: string | Error | (string | Error)[], + breakLine = false, + ): void { + const stack: string = new Error().stack || ""; + const { filename, timestamp } = this.getCallerInfo(stack); + + const messages: (string | Error)[] = Array.isArray(message) + ? message + : [message]; + const joinedMessage: string = messages + .map((msg: string | Error): string => + typeof msg === "string" ? msg : msg.message, + ) + .join(" "); + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: "[ERROR]", color: "31" }, + filename: { value: `(${filename})`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog(`${timestamp} [ERROR] (${filename}) ${joinedMessage}`); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public custom( + bracketMessage: string, + bracketMessage2: string, + message: string | string[], + color: string, + breakLine = false, + ): void { + const stack: string = new Error().stack || ""; + const { timestamp } = this.getCallerInfo(stack); + + const joinedMessage: string = Array.isArray(message) + ? message.join(" ") + : message; + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: bracketMessage, color }, + filename: { value: `${bracketMessage2}`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog( + `${timestamp} ${bracketMessage} (${bracketMessage2}) ${joinedMessage}`, + ); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public space(): void { + console.log(); + } + + private writeConsoleMessageColored( + logMessageParts: ILogMessageParts, + breakLine = false, + ): void { + const logMessage: string = Object.keys(logMessageParts) + .map((key: string) => { + const part: ILogMessagePart = logMessageParts[key]; + return `\x1b[${part.color}m${part.value}\x1b[0m`; + }) + .join(" "); + console.log(logMessage + (breakLine ? EOL : "")); + } +} + +const logger: Logger = Logger.getInstance(); +export { logger }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..60606d4 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +import { logger } from "@helpers/logger"; + +import { serverHandler } from "@/server"; + +async function main(): Promise { + serverHandler.initialize(); +} + +main().catch((error: Error) => { + logger.error(["Error initializing the server:", error]); + process.exit(1); +}); diff --git a/src/routes/[id].ts b/src/routes/[id].ts new file mode 100644 index 0000000..94b5ab5 --- /dev/null +++ b/src/routes/[id].ts @@ -0,0 +1,96 @@ +import { badgeServices } from "@config/environment"; +import { fetchBadges } from "@helpers/badges"; +import { parseServices, validateID } from "@helpers/char"; + +function isValidServices(services: string[]): boolean { + if (!Array.isArray(services)) return false; + if (services.length === 0) return false; + + const validServices = badgeServices.map((s) => s.service.toLowerCase()); + return services.every((s) => validServices.includes(s.toLowerCase())); +} + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(request: ExtendedRequest): Promise { + const { id: userId } = request.params; + const { services, cache, seperated } = request.query; + + let validServices: string[]; + + if (!validateID(userId)) { + return Response.json( + { + status: 400, + error: "Invalid Discord User ID", + }, + { + status: 400, + }, + ); + } + + if (services) { + const parsed = parseServices(services); + + if (parsed.length > 0) { + if (!isValidServices(parsed)) { + return Response.json( + { + status: 400, + error: "Invalid Services", + }, + { + status: 400, + }, + ); + } + + validServices = parsed; + } else { + validServices = badgeServices.map((b) => b.service); + } + } else { + validServices = badgeServices.map((b) => b.service); + } + + const badges: BadgeResult = await fetchBadges(userId, validServices, { + nocache: cache !== "true", + separated: seperated === "true", + }); + + if (badges instanceof Error) { + return Response.json( + { + status: 500, + error: badges.message, + }, + { + status: 500, + }, + ); + } + + if (badges.length === 0) { + return Response.json( + { + status: 404, + error: "No Badges Found", + }, + { + status: 404, + }, + ); + } + + return Response.json({ + status: 200, + badges, + }); +} + +export { handler, routeDef }; diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..76e5c37 --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,22 @@ +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(request: ExtendedRequest): Promise { + const endPerf: number = Date.now(); + const perf: number = endPerf - request.startPerf; + + const { query, params } = request; + + const response: Record = { + perf, + query, + params, + }; + + return Response.json(response); +} + +export { handler, routeDef }; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..909813c --- /dev/null +++ b/src/server.ts @@ -0,0 +1,254 @@ +import { resolve } from "node:path"; +import { environment } from "@config/environment"; +import { logger } from "@helpers/logger"; +import { + type BunFile, + FileSystemRouter, + type MatchedRoute, + type Serve, +} from "bun"; + +import { webSocketHandler } from "@/websocket"; + +class ServerHandler { + private router: FileSystemRouter; + + constructor( + private port: number, + private host: string, + ) { + this.router = new FileSystemRouter({ + style: "nextjs", + dir: "./src/routes", + fileExtensions: [".ts"], + origin: `http://${this.host}:${this.port}`, + }); + } + + public initialize(): void { + const server: Serve = Bun.serve({ + port: this.port, + hostname: this.host, + fetch: this.handleRequest.bind(this), + websocket: { + open: webSocketHandler.handleOpen.bind(webSocketHandler), + message: webSocketHandler.handleMessage.bind(webSocketHandler), + close: webSocketHandler.handleClose.bind(webSocketHandler), + }, + }); + + logger.info( + `Server running at http://${server.hostname}:${server.port}`, + true, + ); + + this.logRoutes(); + } + + private logRoutes(): void { + logger.info("Available routes:"); + + const sortedRoutes: [string, string][] = Object.entries( + this.router.routes, + ).sort(([pathA]: [string, string], [pathB]: [string, string]) => + pathA.localeCompare(pathB), + ); + + for (const [path, filePath] of sortedRoutes) { + logger.info(`Route: ${path}, File: ${filePath}`); + } + } + + private async serveStaticFile(pathname: string): Promise { + try { + let filePath: string; + + if (pathname === "/favicon.ico") { + filePath = resolve("public", "assets", "favicon.ico"); + } else { + filePath = resolve(`.${pathname}`); + } + + const file: BunFile = Bun.file(filePath); + + if (await file.exists()) { + const fileContent: ArrayBuffer = await file.arrayBuffer(); + const contentType: string = file.type || "application/octet-stream"; + + return new Response(fileContent, { + headers: { "Content-Type": contentType }, + }); + } + logger.warn(`File not found: ${filePath}`); + return new Response("Not Found", { status: 404 }); + } catch (error) { + logger.error([`Error serving static file: ${pathname}`, error as Error]); + return new Response("Internal Server Error", { status: 500 }); + } + } + + private async handleRequest( + request: Request, + server: BunServer, + ): Promise { + const extendedRequest: ExtendedRequest = request as ExtendedRequest; + extendedRequest.startPerf = performance.now(); + + const pathname: string = new URL(request.url).pathname; + if (pathname.startsWith("/public") || pathname === "/favicon.ico") { + return await this.serveStaticFile(pathname); + } + + const match: MatchedRoute | null = this.router.match(request); + let requestBody: unknown = {}; + let response: Response; + + if (match) { + const { filePath, params, query } = match; + + try { + const routeModule: RouteModule = await import(filePath); + const contentType: string | null = request.headers.get("Content-Type"); + const actualContentType: string | null = contentType + ? contentType.split(";")[0].trim() + : null; + + if ( + routeModule.routeDef.needsBody === "json" && + actualContentType === "application/json" + ) { + try { + requestBody = await request.json(); + } catch { + requestBody = {}; + } + } else if ( + routeModule.routeDef.needsBody === "multipart" && + actualContentType === "multipart/form-data" + ) { + try { + requestBody = await request.formData(); + } catch { + requestBody = {}; + } + } + + if ( + (Array.isArray(routeModule.routeDef.method) && + !routeModule.routeDef.method.includes(request.method)) || + (!Array.isArray(routeModule.routeDef.method) && + routeModule.routeDef.method !== request.method) + ) { + response = Response.json( + { + success: false, + code: 405, + error: `Method ${request.method} Not Allowed, expected ${ + Array.isArray(routeModule.routeDef.method) + ? routeModule.routeDef.method.join(", ") + : routeModule.routeDef.method + }`, + }, + { status: 405 }, + ); + } else { + const expectedContentType: string | string[] | null = + routeModule.routeDef.accepts; + + let matchesAccepts: boolean; + + if (Array.isArray(expectedContentType)) { + matchesAccepts = + expectedContentType.includes("*/*") || + expectedContentType.includes(actualContentType || ""); + } else { + matchesAccepts = + expectedContentType === "*/*" || + actualContentType === expectedContentType; + } + + if (!matchesAccepts) { + response = Response.json( + { + success: false, + code: 406, + error: `Content-Type ${actualContentType} Not Acceptable, expected ${ + Array.isArray(expectedContentType) + ? expectedContentType.join(", ") + : expectedContentType + }`, + }, + { status: 406 }, + ); + } else { + extendedRequest.params = params; + extendedRequest.query = query; + + response = await routeModule.handler( + extendedRequest, + requestBody, + server, + ); + + if (routeModule.routeDef.returns !== "*/*") { + response.headers.set( + "Content-Type", + routeModule.routeDef.returns, + ); + } + } + } + } catch (error: unknown) { + logger.error([`Error handling route ${request.url}:`, error as Error]); + + response = Response.json( + { + success: false, + code: 500, + error: "Internal Server Error", + }, + { status: 500 }, + ); + } + } else { + response = Response.json( + { + success: false, + code: 404, + error: "Not Found", + }, + { status: 404 }, + ); + } + + const headers = request.headers; + let ip = server.requestIP(request)?.address; + + if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") { + ip = + headers.get("CF-Connecting-IP")?.trim() || + headers.get("X-Real-IP")?.trim() || + headers.get("X-Forwarded-For")?.split(",")[0].trim() || + "unknown"; + } + + logger.custom( + `[${request.method}]`, + `(${response.status})`, + [ + request.url, + `${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`, + ip || "unknown", + ], + "90", + ); + + return response; + } +} +const serverHandler: ServerHandler = new ServerHandler( + environment.port, + environment.host, +); + +export { serverHandler }; diff --git a/src/websocket.ts b/src/websocket.ts new file mode 100644 index 0000000..99686e8 --- /dev/null +++ b/src/websocket.ts @@ -0,0 +1,30 @@ +import { logger } from "@helpers/logger"; +import type { ServerWebSocket } from "bun"; + +class WebSocketHandler { + public handleMessage(ws: ServerWebSocket, message: string): void { + logger.info(`WebSocket received: ${message}`); + try { + ws.send(`You said: ${message}`); + } catch (error) { + logger.error(["WebSocket send error", error as Error]); + } + } + + public handleOpen(ws: ServerWebSocket): void { + logger.info("WebSocket connection opened."); + try { + ws.send("Welcome to the WebSocket server!"); + } catch (error) { + logger.error(["WebSocket send error", error as Error]); + } + } + + public handleClose(ws: ServerWebSocket, code: number, reason: string): void { + logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); + } +} + +const webSocketHandler: WebSocketHandler = new WebSocketHandler(); + +export { webSocketHandler }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..68a5a97 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["src/*"], + "@config/*": ["config/*"], + "@types/*": ["types/*"], + "@helpers/*": ["src/helpers/*"] + }, + "typeRoots": ["./src/types", "./node_modules/@types"], + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src", "types", "config"] +} diff --git a/types/badge.d.ts b/types/badge.d.ts new file mode 100644 index 0000000..04b58c9 --- /dev/null +++ b/types/badge.d.ts @@ -0,0 +1,11 @@ +type Badge = { + tooltip: string; + badge: string; +}; + +type BadgeResult = Badge[] | Record; + +interface FetchBadgesOptions { + nocache?: boolean; + separated?: boolean; +} diff --git a/types/bun.d.ts b/types/bun.d.ts new file mode 100644 index 0000000..018bf35 --- /dev/null +++ b/types/bun.d.ts @@ -0,0 +1,14 @@ +import type { Server } from "bun"; + +type Query = Record; +type Params = Record; + +declare global { + type BunServer = Server; + + interface ExtendedRequest extends Request { + startPerf: number; + query: Query; + params: Params; + } +} diff --git a/types/config.d.ts b/types/config.d.ts new file mode 100644 index 0000000..2d583ee --- /dev/null +++ b/types/config.d.ts @@ -0,0 +1,10 @@ +type Environment = { + port: number; + host: string; + development: boolean; +}; + +type badgeURLMap = { + service: string; + url: string | ((userId: string) => string); +}; diff --git a/types/logger.d.ts b/types/logger.d.ts new file mode 100644 index 0000000..ff6a601 --- /dev/null +++ b/types/logger.d.ts @@ -0,0 +1,9 @@ +type ILogMessagePart = { value: string; color: string }; + +type ILogMessageParts = { + level: ILogMessagePart; + filename: ILogMessagePart; + readableTimestamp: ILogMessagePart; + message: ILogMessagePart; + [key: string]: ILogMessagePart; +}; diff --git a/types/routes.d.ts b/types/routes.d.ts new file mode 100644 index 0000000..9d9d809 --- /dev/null +++ b/types/routes.d.ts @@ -0,0 +1,15 @@ +type RouteDef = { + method: string | string[]; + accepts: string | null | string[]; + returns: string; + needsBody?: "multipart" | "json"; +}; + +type RouteModule = { + handler: ( + request: Request | ExtendedRequest, + requestBody: unknown, + server: BunServer, + ) => Promise | Response; + routeDef: RouteDef; +};