commit 44077f3ccd7547de66c90dd20139a0e1e1ffd8e7 Author: creations Date: Thu May 1 13:53:38 2025 -0400 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc41d6e --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# bun frontend template + +a simple bun frontend starting point i made and use 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..cdd86e8 --- /dev/null +++ b/config/environment.ts @@ -0,0 +1,6 @@ +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"), +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..287df14 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "void_backend", + "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.10", + "@types/ejs": "^3.1.5", + "globals": "^16.0.0", + "@biomejs/biome": "^1.9.4" + }, + "peerDependencies": { + "typescript": "^5.8.2" + }, + "dependencies": { + "@creations.works/logger": "^1.0.3" + } +} diff --git a/public/assets/favicon.ico b/public/assets/favicon.ico new file mode 100644 index 0000000..69ec50d Binary files /dev/null and b/public/assets/favicon.ico differ diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c4148b4 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +import { logger } from "@creations.works/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/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..5646636 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,253 @@ +import { resolve } from "node:path"; +import { environment } from "@config/environment"; +import { logger } from "@creations.works/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}`, { + breakLine: 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..7b65476 --- /dev/null +++ b/src/websocket.ts @@ -0,0 +1,30 @@ +import { logger } from "@creations.works/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/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..57584ed --- /dev/null +++ b/types/config.d.ts @@ -0,0 +1,5 @@ +type Environment = { + port: number; + host: string; + development: boolean; +}; 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; +};