diff --git a/.gitignore b/.gitignore index e27b366..ad20b9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /node_modules bun.lock robots.txt +logs +public/custom diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d93a942 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2025, creations.works + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index fc41d6e..085c2a7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,67 @@ -# bun frontend template +# Bun Frontend Template -a simple bun frontend starting point i made and use +A minimal, fast, and type-safe web server template built with [Bun](https://bun.sh) and TypeScript. Features file-system based routing, static file serving, WebSocket support, and structured logging. + +## Configuration + +### Environment Variables + +| Variable | Description | Default | Required | +|----------|-------------|---------|----------| +| `HOST` | Server host address | `0.0.0.0` | ✅ | +| `PORT` | Server port | `8080` | ✅ | +| `NODE_ENV` | Environment mode | `production` | ❌ | + +### Creating Routes + +Routes are automatically generated from files in `src/routes/`. Each route file exports: + +```typescript +// src/routes/example.ts +const routeDef: RouteDef = { + method: "GET", // HTTP method(s) + accepts: "application/json", // Content-Type validation + returns: "application/json", // Response Content-Type + needsBody?: "json" | "multipart" // Optional body parsing, dont include if neither are required +}; + +async function handler( + request: ExtendedRequest, + requestBody: unknown, + server: BunServer +): Promise { + return Response.json({ message: "Hello World" }); +} + +export { handler, routeDef }; +``` + +### Route Features + +- **Method Validation** - Automatic HTTP method checking +- **Content-Type Validation** - Request/response content type enforcement +- **Body Parsing** - Automatic JSON/FormData parsing +- **Query Parameters** - Automatic query string parsing +- **URL Parameters** - Next.js-style dynamic routes (`[id].ts`) + +## Static Files + +Place files in `public/` directory + +### Custom Public Files + +Files in `public/custom/` are served with security checks: +- Path traversal protection +- Content-type detection +- Direct file serving + +## License + +This project is licensed under the BSD-3-Clause - see the [LICENSE](LICENSE) file for details. + +## Dependencies + +- **[@atums/echo](https://www.npmjs.com/package/@atums/echo)** - Structured logging with daily rotation +- **[Bun](https://bun.sh)** - Fast JavaScript runtime and bundler +- **[TypeScript](https://www.typescriptlang.org/)** - Type-safe JavaScript +- **[Biome](https://biomejs.dev/)** - Fast formatter and linter diff --git a/biome.json b/biome.json index 921a7a5..3a44cc4 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": true, - "ignore": [] + "ignore": ["dist"] }, "formatter": { "enabled": true, @@ -17,11 +17,29 @@ "organizeImports": { "enabled": true }, + "css": { + "formatter": { + "indentStyle": "tab", + "lineEnding": "lf" + } + }, "linter": { "enabled": true, "rules": { - "recommended": true - } + "recommended": true, + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error" + }, + "suspicious": { + "noConsole": "error" + }, + "style": { + "useConst": "error", + "noVar": "error" + } + }, + "ignore": ["types"] }, "javascript": { "formatter": { diff --git a/config/environment.ts b/config/environment.ts index c203c7e..543cd38 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -1,5 +1,4 @@ -import { resolve } from "node:path"; -import { logger } from "@creations.works/logger"; +import { echo } from "@atums/echo"; const environment: Environment = { port: Number.parseInt(process.env.PORT || "8080", 10), @@ -8,10 +7,6 @@ const environment: Environment = { process.env.NODE_ENV === "development" || process.argv.includes("--dev"), }; -const robotstxtPath: string | null = process.env.ROBOTS_FILE - ? resolve(process.env.ROBOTS_FILE) - : null; - function verifyRequiredVariables(): void { const requiredVariables = ["HOST", "PORT"]; @@ -20,7 +15,7 @@ function verifyRequiredVariables(): void { for (const key of requiredVariables) { const value = process.env[key]; if (value === undefined || value.trim() === "") { - logger.error(`Missing or empty environment variable: ${key}`); + echo.error(`Missing or empty environment variable: ${key}`); hasError = true; } } @@ -30,4 +25,4 @@ function verifyRequiredVariables(): void { } } -export { environment, robotstxtPath, verifyRequiredVariables }; +export { environment, verifyRequiredVariables }; diff --git a/logger.json b/logger.json new file mode 100644 index 0000000..521b3bc --- /dev/null +++ b/logger.json @@ -0,0 +1,39 @@ +{ + "directory": "logs", + "level": "debug", + "disableFile": false, + + "rotate": true, + "maxFiles": 3, + + "console": true, + "consoleColor": true, + + "dateFormat": "yyyy-MM-dd HH:mm:ss.SSS", + "timezone": "local", + + "silent": false, + + "pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}", + "levelColor": { + "debug": "blue", + "info": "green", + "warn": "yellow", + "error": "red", + "fatal": "red" + }, + + "customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}", + "customColors": { + "GET": "green", + "POST": "blue", + "PUT": "yellow", + "DELETE": "red", + "PATCH": "cyan", + "HEAD": "magenta", + "OPTIONS": "white", + "TRACE": "gray" + }, + + "prettyPrint": true +} diff --git a/package.json b/package.json index a9f6143..fd0cd43 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,13 @@ "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" + "@types/bun": "latest", + "@biomejs/biome": "latest" }, "peerDependencies": { - "typescript": "^5.8.2" + "typescript": "latest" }, "dependencies": { - "@creations.works/logger": "^1.0.3", - "ejs": "^3.1.10" + "@atums/echo": "latest" } } diff --git a/src/index.ts b/src/index.ts index 6235bd6..a482ad1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ -import { logger } from "@creations.works/logger"; +import { echo } from "@atums/echo"; -import { serverHandler } from "@/server"; import { verifyRequiredVariables } from "@config/environment"; +import { serverHandler } from "@server"; async function main(): Promise { verifyRequiredVariables(); @@ -10,6 +10,6 @@ async function main(): Promise { } main().catch((error: Error) => { - logger.error(["Error initializing the server:", error]); + echo.error({ message: "Error initializing the server:", error }); process.exit(1); }); diff --git a/src/server.ts b/src/server.ts index 066b2ef..9492da2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,14 @@ import { resolve } from "node:path"; -import { environment, robotstxtPath } from "@config/environment"; -import { logger } from "@creations.works/logger"; +import { echo } from "@atums/echo"; +import { environment } from "@config/environment"; import { type BunFile, FileSystemRouter, type MatchedRoute, - type Serve, + type Server, } from "bun"; -import { webSocketHandler } from "@/websocket"; +import { webSocketHandler } from "@websocket"; class ServerHandler { private router: FileSystemRouter; @@ -19,14 +19,14 @@ class ServerHandler { ) { this.router = new FileSystemRouter({ style: "nextjs", - dir: "./src/routes", + dir: resolve("src", "routes"), fileExtensions: [".ts"], origin: `http://${this.host}:${this.port}`, }); } public initialize(): void { - const server: Serve = Bun.serve({ + const server: Server = Bun.serve({ port: this.port, hostname: this.host, fetch: this.handleRequest.bind(this), @@ -37,15 +37,13 @@ class ServerHandler { }, }); - logger.info(`Server running at http://${server.hostname}:${server.port}`, { - breakLine: true, - }); + echo.info(`Server running at http://${server.hostname}:${server.port}`); this.logRoutes(); } private logRoutes(): void { - logger.info("Available routes:"); + echo.info("Available routes:"); const sortedRoutes: [string, string][] = Object.entries( this.router.routes, @@ -54,14 +52,19 @@ class ServerHandler { ); for (const [path, filePath] of sortedRoutes) { - logger.info(`Route: ${path}, File: ${filePath}`); + echo.info(`Route: ${path}, File: ${filePath}`); } } - private async serveStaticFile(pathname: string): Promise { - try { - let filePath: string; + private async serveStaticFile( + request: ExtendedRequest, + pathname: string, + ip: string, + ): Promise { + let filePath: string; + let response: Response; + try { if (pathname === "/favicon.ico") { filePath = resolve("public", "assets", "favicon.ico"); } else { @@ -72,18 +75,49 @@ class ServerHandler { if (await file.exists()) { const fileContent: ArrayBuffer = await file.arrayBuffer(); - const contentType: string = file.type || "application/octet-stream"; + const contentType: string = file.type ?? "application/octet-stream"; - return new Response(fileContent, { + response = new Response(fileContent, { headers: { "Content-Type": contentType }, }); + } else { + echo.warn(`File not found: ${filePath}`); + response = new Response("Not Found", { status: 404 }); } - 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 }); + echo.error({ + message: `Error serving static file: ${pathname}`, + error: error as Error, + }); + response = new Response("Internal Server Error", { status: 500 }); } + + this.logRequest(request, response, ip); + return response; + } + + private logRequest( + request: ExtendedRequest, + response: Response, + ip: string | undefined, + ): void { + const pathname = new URL(request.url).pathname; + + const ignoredStartsWith: string[] = ["/public"]; + const ignoredPaths: string[] = ["/favicon.ico"]; + + if ( + ignoredStartsWith.some((prefix) => pathname.startsWith(prefix)) || + ignoredPaths.includes(pathname) + ) { + return; + } + + echo.custom(`${request.method}`, `${response.status}`, [ + request.url, + `${(performance.now() - request.startPerf).toFixed(2)}ms`, + ip || "unknown", + ]); } private async handleRequest( @@ -95,44 +129,44 @@ class ServerHandler { const headers = request.headers; let ip = server.requestIP(request)?.address; + let response: Response; 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() || + headers.get("X-Forwarded-For")?.split(",")[0]?.trim() || "unknown"; } const pathname: string = new URL(request.url).pathname; - if (pathname === "/robots.txt" && robotstxtPath) { - try { - const file: BunFile = Bun.file(robotstxtPath); - if (await file.exists()) { - const fileContent: ArrayBuffer = await file.arrayBuffer(); - const contentType: string = file.type || "text/plain"; - return new Response(fileContent, { - headers: { "Content-Type": contentType }, - }); - } - logger.warn(`File not found: ${robotstxtPath}`); - return new Response("Not Found", { status: 404 }); - } catch (error) { - logger.error([ - `Error serving robots.txt: ${robotstxtPath}`, - error as Error, - ]); - return new Response("Internal Server Error", { status: 500 }); - } + + const baseDir = resolve("public", "custom"); + const customPath = resolve(baseDir, pathname.slice(1)); + + if (!customPath.startsWith(baseDir)) { + response = new Response("Forbidden", { status: 403 }); + this.logRequest(extendedRequest, response, ip); + return response; + } + + const customFile = Bun.file(customPath); + if (await customFile.exists()) { + const content = await customFile.arrayBuffer(); + const type: string = customFile.type ?? "application/octet-stream"; + response = new Response(content, { + headers: { "Content-Type": type }, + }); + this.logRequest(extendedRequest, response, ip); + return response; } if (pathname.startsWith("/public") || pathname === "/favicon.ico") { - return await this.serveStaticFile(pathname); + return await this.serveStaticFile(extendedRequest, pathname, ip); } const match: MatchedRoute | null = this.router.match(request); let requestBody: unknown = {}; - let response: Response; if (match) { const { filePath, params, query } = match; @@ -141,7 +175,7 @@ class ServerHandler { const routeModule: RouteModule = await import(filePath); const contentType: string | null = request.headers.get("Content-Type"); const actualContentType: string | null = contentType - ? contentType.split(";")[0].trim() + ? (contentType.split(";")[0]?.trim() ?? null) : null; if ( @@ -230,7 +264,10 @@ class ServerHandler { } } } catch (error: unknown) { - logger.error([`Error handling route ${request.url}:`, error as Error]); + echo.error({ + message: `Error handling route ${request.url}`, + error: error, + }); response = Response.json( { @@ -252,20 +289,11 @@ class ServerHandler { ); } - logger.custom( - `[${request.method}]`, - `(${response.status})`, - [ - request.url, - `${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`, - ip || "unknown", - ], - "90", - ); - + this.logRequest(extendedRequest, response, ip); return response; } } + const serverHandler: ServerHandler = new ServerHandler( environment.port, environment.host, diff --git a/src/websocket.ts b/src/websocket.ts index 7b65476..87ef56e 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,30 +1,29 @@ -import { logger } from "@creations.works/logger"; +import { echo } from "@atums/echo"; import type { ServerWebSocket } from "bun"; class WebSocketHandler { public handleMessage(ws: ServerWebSocket, message: string): void { - logger.info(`WebSocket received: ${message}`); + echo.info(`WebSocket received: ${message}`); try { ws.send(`You said: ${message}`); } catch (error) { - logger.error(["WebSocket send error", error as Error]); + echo.error({ message: "WebSocket send error", error }); } } public handleOpen(ws: ServerWebSocket): void { - logger.info("WebSocket connection opened."); + echo.info("WebSocket connection opened."); try { ws.send("Welcome to the WebSocket server!"); } catch (error) { - logger.error(["WebSocket send error", error as Error]); + echo.error({ message: "WebSocket send error", error }); } } - public handleClose(ws: ServerWebSocket, code: number, reason: string): void { - logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); + public handleClose(_ws: ServerWebSocket, code: number, reason: string): void { + echo.warn(`WebSocket closed with code ${code}, reason: ${reason}`); } } const webSocketHandler: WebSocketHandler = new WebSocketHandler(); - -export { webSocketHandler }; +export { webSocketHandler, WebSocketHandler }; diff --git a/tsconfig.json b/tsconfig.json index 68a5a97..1e03dcd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,32 +2,38 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "@/*": ["src/*"], + "@*": ["src/*"], "@config/*": ["config/*"], "@types/*": ["types/*"], "@helpers/*": ["src/helpers/*"] }, - "typeRoots": ["./src/types", "./node_modules/@types"], - // Enable latest features - "lib": ["ESNext", "DOM"], + "typeRoots": [ + "./types", + "./node_modules/@types" + ], + "lib": [ + "ESNext", + "DOM" + ], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - // Bundler mode + "allowJs": false, "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "allowImportingTsExtensions": false, "verbatimModuleSyntax": true, "noEmit": true, - // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, "noPropertyAccessFromIndexSignature": false }, - "include": ["src", "types", "config"] + "include": [ + "src", + "types" + ] }