This commit is contained in:
commit
cfbcaa4851
25 changed files with 1096 additions and 0 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
@ -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
|
6
.env.example
Normal file
6
.env.example
Normal file
|
@ -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
|
24
.forgejo/workflows/biomejs.yml
Normal file
24
.forgejo/workflows/biomejs.yml
Normal file
|
@ -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
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
* text=auto eol=lf
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/node_modules
|
||||||
|
bun.lock
|
||||||
|
.env
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -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.
|
91
README.md
Normal file
91
README.md
Normal file
|
@ -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)
|
35
biome.json
Normal file
35
biome.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
38
config/environment.ts
Normal file
38
config/environment.ts
Normal file
|
@ -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,
|
||||||
|
// }
|
||||||
|
];
|
24
package.json
Normal file
24
package.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
BIN
public/assets/favicon.ico
Normal file
BIN
public/assets/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
111
src/helpers/badges.ts
Normal file
111
src/helpers/badges.ts
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { badgeServices, redisTtl } from "@config/environment";
|
||||||
|
import { fetch, redis } from "bun";
|
||||||
|
|
||||||
|
export async function fetchBadges(
|
||||||
|
userId: string,
|
||||||
|
services: string[],
|
||||||
|
options?: FetchBadgesOptions,
|
||||||
|
): Promise<BadgeResult> {
|
||||||
|
const { nocache = false, separated = false } = options ?? {};
|
||||||
|
const results: Record<string, Badge[]> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
19
src/helpers/char.ts
Normal file
19
src/helpers/char.ts
Normal file
|
@ -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);
|
||||||
|
}
|
205
src/helpers/logger.ts
Normal file
205
src/helpers/logger.ts
Normal file
|
@ -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 };
|
12
src/index.ts
Normal file
12
src/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { logger } from "@helpers/logger";
|
||||||
|
|
||||||
|
import { serverHandler } from "@/server";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
serverHandler.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: Error) => {
|
||||||
|
logger.error(["Error initializing the server:", error]);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
96
src/routes/[id].ts
Normal file
96
src/routes/[id].ts
Normal file
|
@ -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<Response> {
|
||||||
|
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 };
|
22
src/routes/index.ts
Normal file
22
src/routes/index.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
const routeDef: RouteDef = {
|
||||||
|
method: "GET",
|
||||||
|
accepts: "*/*",
|
||||||
|
returns: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
|
const endPerf: number = Date.now();
|
||||||
|
const perf: number = endPerf - request.startPerf;
|
||||||
|
|
||||||
|
const { query, params } = request;
|
||||||
|
|
||||||
|
const response: Record<string, unknown> = {
|
||||||
|
perf,
|
||||||
|
query,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response.json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler, routeDef };
|
254
src/server.ts
Normal file
254
src/server.ts
Normal file
|
@ -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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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 };
|
30
src/websocket.ts
Normal file
30
src/websocket.ts
Normal file
|
@ -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 };
|
33
tsconfig.json
Normal file
33
tsconfig.json
Normal file
|
@ -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"]
|
||||||
|
}
|
11
types/badge.d.ts
vendored
Normal file
11
types/badge.d.ts
vendored
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
type Badge = {
|
||||||
|
tooltip: string;
|
||||||
|
badge: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BadgeResult = Badge[] | Record<string, Badge[]>;
|
||||||
|
|
||||||
|
interface FetchBadgesOptions {
|
||||||
|
nocache?: boolean;
|
||||||
|
separated?: boolean;
|
||||||
|
}
|
14
types/bun.d.ts
vendored
Normal file
14
types/bun.d.ts
vendored
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Server } from "bun";
|
||||||
|
|
||||||
|
type Query = Record<string, string>;
|
||||||
|
type Params = Record<string, string>;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
type BunServer = Server;
|
||||||
|
|
||||||
|
interface ExtendedRequest extends Request {
|
||||||
|
startPerf: number;
|
||||||
|
query: Query;
|
||||||
|
params: Params;
|
||||||
|
}
|
||||||
|
}
|
10
types/config.d.ts
vendored
Normal file
10
types/config.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
type Environment = {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
development: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type badgeURLMap = {
|
||||||
|
service: string;
|
||||||
|
url: string | ((userId: string) => string);
|
||||||
|
};
|
9
types/logger.d.ts
vendored
Normal file
9
types/logger.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
type ILogMessagePart = { value: string; color: string };
|
||||||
|
|
||||||
|
type ILogMessageParts = {
|
||||||
|
level: ILogMessagePart;
|
||||||
|
filename: ILogMessagePart;
|
||||||
|
readableTimestamp: ILogMessagePart;
|
||||||
|
message: ILogMessagePart;
|
||||||
|
[key: string]: ILogMessagePart;
|
||||||
|
};
|
15
types/routes.d.ts
vendored
Normal file
15
types/routes.d.ts
vendored
Normal file
|
@ -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> | Response;
|
||||||
|
routeDef: RouteDef;
|
||||||
|
};
|
Loading…
Add table
Reference in a new issue