Migrate to @atums/echo logger and improve project structure
Some checks failed
Code quality checks / biome (push) Failing after 12s

This commit is contained in:
creations 2025-06-02 20:34:50 -04:00
parent 490cd9d0e0
commit fdce8810cf
Signed by: creations
GPG key ID: 8F553AA4320FC711
11 changed files with 276 additions and 100 deletions

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
/node_modules
bun.lock
robots.txt
logs
public/custom

28
LICENSE Normal file
View file

@ -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.

View file

@ -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<Response> {
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

View file

@ -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": {

View file

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

39
logger.json Normal file
View file

@ -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
}

View file

@ -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"
}
}

View file

@ -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<void> {
verifyRequiredVariables();
@ -10,6 +10,6 @@ async function main(): Promise<void> {
}
main().catch((error: Error) => {
logger.error(["Error initializing the server:", error]);
echo.error({ message: "Error initializing the server:", error });
process.exit(1);
});

View file

@ -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<Response> {
try {
let filePath: string;
private async serveStaticFile(
request: ExtendedRequest,
pathname: string,
ip: string,
): Promise<Response> {
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,

View file

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

View file

@ -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"
]
}