├── .dockerignore ├── .editorconfig ├── .env.docker ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── Dockerfile ├── README.md ├── SECURITY.md ├── bun.lockb ├── docker-compose.yml ├── eslint.config.mjs ├── mongodb └── mongo-init.js ├── package.json ├── src ├── app.ts ├── config │ ├── db.ts │ └── index.ts ├── controllers │ ├── auth.ts │ └── user.ts ├── domain │ ├── exceptions │ │ ├── ConflictError.ts │ │ ├── MongoServerError.ts │ │ └── UnauthorizedError.ts │ └── types │ │ ├── LoggedInUser.ts │ │ ├── extends │ │ ├── ContextWithJWT.ts │ │ └── ContextWithUser.ts │ │ └── generics │ │ ├── ErrorResponse.ts │ │ └── SuccessResponse.ts ├── models │ └── User.ts ├── plugins │ ├── authenticate.ts │ ├── error.ts │ ├── logger.ts │ └── security.ts ├── routes │ ├── auth.ts │ ├── protected.ts │ └── user.ts ├── schema │ └── authSchema.ts ├── services │ ├── auth.ts │ └── user.ts └── utils │ └── logger.ts ├── test ├── home.test.ts └── index.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | Dockerfile* 3 | docker-compose* 4 | .dockerignore 5 | .git 6 | .gitignore 7 | README.md 8 | LICENSE 9 | .vscode 10 | Makefile 11 | helm-charts 12 | .env 13 | .editorconfig 14 | .idea 15 | coverage* 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | charset = utf-8 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | max_line_length = 120 10 | 11 | [LICENSE] 12 | insert_final_newline = false 13 | -------------------------------------------------------------------------------- /.env.docker: -------------------------------------------------------------------------------- 1 | DB_NAME=starter 2 | DB_PASSWORD=secret 3 | DB_USERNAME=starter 4 | DB_PORT=27017 5 | DB_HOST=mongodb 6 | 7 | APP_PORT=8000 8 | 9 | TEST_APP_HOST=127.0.0.1 10 | TEST_APP_PORT=8001 11 | 12 | JWT_SECRET=JWT_SECRET 13 | JWT_EXPIRES_IN=1d 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["sonarjs", "simple-import-sort", "jsdoc"], 5 | "parserOptions": { 6 | "ecmaVersion": "latest", 7 | "sourceType": "module" 8 | }, 9 | "ignorePatterns": ["./node_modules/*"], 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "plugin:prettier/recommended", 14 | "plugin:sonarjs/recommended", 15 | "plugin:jsdoc/recommended-typescript" 16 | ], 17 | "rules": { 18 | "no-error-on-unmatched-pattern": "off", 19 | "@typescript-eslint/no-explicit-any": "warn", 20 | "sonarjs/cognitive-complexity": "off", 21 | "@typescript-eslint/no-shadow": ["error", { "hoist": "all"}], 22 | "@typescript-eslint/ban-ts-comment": "off", 23 | "simple-import-sort/imports": "warn", 24 | "jsdoc/no-types": "off", 25 | "jsdoc/tag-lines": "off", 26 | "jsdoc/require-returns-description": "off" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Bun (🍔) API Starter CI 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | pull_request: 6 | paths-ignore: 7 | - "README.md" 8 | - "mongodb" 9 | branches: 10 | - main 11 | push: 12 | paths-ignore: 13 | - "README.md" 14 | branches: 15 | - main 16 | 17 | jobs: 18 | test: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Cache 25 | uses: actions/cache@v4 26 | with: 27 | path: ~/.bun/install/cache 28 | key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} 29 | restore-keys: | 30 | ${{ runner.os }}-bun- 31 | 32 | - name: Install bun 33 | uses: oven-sh/setup-bun@v1 34 | 35 | - name: Start MongoDB 36 | uses: supercharge/mongodb-github-action@1.10.0 37 | with: 38 | mongodb-username: starter 39 | mongodb-password: secret 40 | mongodb-db: starter 41 | 42 | - name: Installing dependencies 43 | run: bun install 44 | 45 | - name: Run linter 46 | run: bun run lint 47 | 48 | - name: Run tests 49 | run: bun run test 50 | env: 51 | DB_NAME: starter 52 | DB_PASSWORD: secret 53 | DB_USERNAME: starter 54 | DB_PORT: 27017 55 | DB_HOST: mongodb 56 | TEST_APP_HOST: 127.0.0.1 57 | TEST_APP_PORT: 8001 58 | JWT_SECRET: secret 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | **/*.trace 37 | **/*.zip 38 | **/*.tar.gz 39 | **/*.tgz 40 | **/*.log 41 | package-lock.json 42 | **/*.bun 43 | 44 | # environment variables 45 | .env 46 | 47 | # docker volumes 48 | data 49 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | bunx lint-staged 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "parser": "typescript", 6 | "printWidth": 120, 7 | "tabWidth": 2 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # base image 2 | FROM oven/bun:latest as base 3 | WORKDIR /usr/source 4 | USER bun 5 | 6 | # install dependencies and cache them for future builds. 7 | FROM base AS install 8 | WORKDIR /temp/dev 9 | COPY package.json bun.lockb . 10 | RUN bun install --frozen-lockfile 11 | 12 | # install dependencies with --production (exclude devDependencies) 13 | WORKDIR /temp/prod 14 | COPY package.json bun.lockb . 15 | RUN bun install --frozen-lockfile --production 16 | 17 | # copy node_modules from temp directory 18 | # then copy all (non-ignored) project files into the image 19 | FROM base AS prerelease 20 | COPY --from=install /temp/dev/node_modules node_modules 21 | COPY . . 22 | 23 | ENV NODE_ENV=test 24 | # todo: add unit test command 25 | 26 | # copy production dependencies and source code into final image 27 | FROM base AS production 28 | COPY --from=install /temp/prod/node_modules node_modules 29 | COPY --from=prerelease /usr/source/src/ ./src/ 30 | COPY --from=prerelease /usr/source/package.json . 31 | 32 | ENV NODE_ENV=production 33 | 34 | EXPOSE 8000/tcp 35 | ENTRYPOINT [ "bun", "run", "start" ] 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bun-api-starter 2 | 3 | [![Bun (🍔) API Starter CI](https://github.com/cham11ng/bun-api-starter/actions/workflows/ci.yml/badge.svg)](https://github.com/cham11ng/bun-api-starter/actions/workflows/ci.yml) 4 | 5 | A robust Bun-based API starter built using ElysiaJS framework, and MongoDB as database. ElysiaJS, a type-safe and fast framework. MongoDB, a powerful NoSQL database. This starter offers a solid foundation for your API development, allowing you to focus on your unique business logic. 6 | 7 | ## Getting Started 8 | 9 | To install bun and starter packages. 10 | 11 | ```bash 12 | curl -fsSL https://bun.sh/install | bash 13 | 14 | bun install 15 | ``` 16 | 17 | ## Development 18 | 19 | To start the development server run: 20 | 21 | ```bash 22 | $ bun run dev 23 | Environment: development 24 | Bun (🍔) API Starter is running at localhost:8000 25 | Info: MongoDB connection successful: starter 26 | ``` 27 | 28 | Open with your browser to see the result. 29 | 30 | ### cURL 31 | 32 | ```bash 33 | curl --request POST \ 34 | --url http://localhost:8000/login \ 35 | --header 'Content-Type: application/json' \ 36 | --data '{ 37 | "email": "mail@example.com", 38 | "password": "secret@123" 39 | }' 40 | ``` 41 | 42 | ### Docker 43 | 44 | ```bash 45 | # development 46 | $ docker compose up -d dev 47 | [+] Running 2/3 48 | ⠋ Network bun-api-starter_default Created 1.0s 49 | ✔ Container starter-mongodb Started 0.5s 50 | ✔ Container starter-dev Started 0.9s 51 | 52 | # check logs 53 | $ docker compose logs -f 54 | ``` 55 | 56 | ```bash 57 | # production 58 | docker build --target production -t bun-api-starter-prod . 59 | docker run -d --rm --env-file .env.docker \ 60 | -p 8080:8000 \ 61 | -t bun-api-starter-prod:latest 62 | ``` 63 | 64 | ### MongoDB Compass 65 | 66 | ```bash 67 | # connect URI for root user. 68 | mongodb://rootuser:root%40123@localhost:27017/starter?authSource=admin 69 | ``` 70 | 71 | ## Troubleshooting 72 | 73 | ```bash 74 | # on default export error 75 | const app = new Elysia(); 76 | export default app; 77 | 78 | # double bun server initiated. 79 | $ Bun (🍔) API Starter is running at localhost:8000 80 | Started server http://localhost:3000 81 | 82 | # do this 83 | export const app = new Elysia(); 84 | ``` 85 | 86 | 87 | ```js 88 | // exception not captured onError. 89 | function controller(context: Context) {} 90 | 91 | // do this 92 | const controller = (context: Context) => {} 93 | ``` 94 | 95 | ## Happy Coding 96 | 97 | > @cham11ng 98 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cham11ng/bun-api-starter/b1c943ef9678d280833bf8b0e872c7659554e14f/bun.lockb -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mongodb: 3 | image: "mongo:latest" 4 | env_file: ".env.docker" 5 | container_name: "starter-mongodb" 6 | volumes: 7 | - "./data/mongodb:/data/db" 8 | - "./mongodb/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro" 9 | ports: 10 | - "27017:27017" 11 | environment: 12 | MONGO_INITDB_ROOT_USERNAME: "rootuser" 13 | MONGO_INITDB_ROOT_PASSWORD: "root@123" 14 | MONGO_INITDB_DATABASE: ${DB_NAME:-starter} 15 | 16 | dev: 17 | image: "oven/bun" 18 | env_file: ".env.docker" 19 | container_name: "starter-dev" 20 | volumes: 21 | - "./:/source" 22 | working_dir: /source 23 | depends_on: 24 | - mongodb 25 | ports: 26 | - 8000:8000 27 | environment: 28 | - NODE_ENV=development 29 | command: bash -c "bun install && bun run dev" 30 | 31 | prod: 32 | build: 33 | context: . 34 | target: production 35 | container_name: 'starter-prod' 36 | env_file: ".env.docker" 37 | depends_on: 38 | - mongodb 39 | ports: 40 | - 8080:8000 41 | environment: 42 | - NODE_ENV=production 43 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | 4 | import { FlatCompat } from '@eslint/eslintrc'; 5 | import js from '@eslint/js'; 6 | import tsParser from '@typescript-eslint/parser'; 7 | import jsdoc from 'eslint-plugin-jsdoc'; 8 | import simpleImportSort from 'eslint-plugin-simple-import-sort'; 9 | import sonarjs from 'eslint-plugin-sonarjs'; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = path.dirname(__filename); 13 | const compat = new FlatCompat({ 14 | baseDirectory: __dirname, 15 | recommendedConfig: js.configs.recommended, 16 | allConfig: js.configs.all 17 | }); 18 | 19 | export default [ 20 | { 21 | ignores: ['./node_modules/*'] 22 | }, 23 | jsdoc.configs['flat/recommended'], 24 | sonarjs.configs.recommended, 25 | ...compat.extends('eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'), 26 | { 27 | plugins: { 28 | 'simple-import-sort': simpleImportSort 29 | }, 30 | 31 | languageOptions: { 32 | parser: tsParser, 33 | ecmaVersion: 'latest', 34 | sourceType: 'module' 35 | }, 36 | 37 | rules: { 38 | 'no-error-on-unmatched-pattern': 'off', 39 | '@typescript-eslint/no-explicit-any': 'warn', 40 | 'sonarjs/cognitive-complexity': 'off', 41 | 'sonarjs/sonar-no-fallthrough': 'off', 42 | '@typescript-eslint/no-shadow': ['error'], 43 | '@typescript-eslint/ban-ts-comment': 'off', 44 | 'simple-import-sort/imports': 'warn', 45 | 'jsdoc/no-types': 'off', 46 | 'jsdoc/tag-lines': 'off', 47 | 'jsdoc/require-returns-description': 'off' 48 | } 49 | } 50 | ]; 51 | -------------------------------------------------------------------------------- /mongodb/mongo-init.js: -------------------------------------------------------------------------------- 1 | db.createUser({ 2 | user: process.env.DB_USERNAME, 3 | pwd: process.env.DB_PASSWORD, 4 | roles: [ 5 | { 6 | role: 'readWrite', 7 | db: process.env.DB_NAME 8 | } 9 | ] 10 | }); 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bun-api-starter", 3 | "version": "1.3.0", 4 | "scripts": { 5 | "test": "NODE_ENV=test bun test ./test/**.test.ts", 6 | "dev": "bun run --watch src/app.ts", 7 | "start": "bun run src/app.ts", 8 | "pretty": "prettier --write \"**/*.{ts,js}\"", 9 | "lint": "eslint src test", 10 | "lint:fix": "eslint src test --fix" 11 | }, 12 | "dependencies": { 13 | "@elysiajs/cors": "^1.1.0", 14 | "@elysiajs/jwt": "^1.1.0", 15 | "@elysiajs/swagger": "^1.1.1", 16 | "elysia": "^1.1.8", 17 | "http-status-codes": "^2.3.0", 18 | "mongoose": "^8.5.4", 19 | "yoctocolors": "^2.1.1" 20 | }, 21 | "devDependencies": { 22 | "@eslint/eslintrc": "^3.1.0", 23 | "@eslint/js": "^9.9.1", 24 | "@typescript-eslint/eslint-plugin": "^8.3.0", 25 | "@typescript-eslint/parser": "^8.3.0", 26 | "bun-types": "^1.1.26", 27 | "eslint": "^9.9.1", 28 | "eslint-config-prettier": "^9.1.0", 29 | "eslint-plugin-jsdoc": "^50.2.2", 30 | "eslint-plugin-prettier": "^5.2.1", 31 | "eslint-plugin-simple-import-sort": "^12.1.1", 32 | "eslint-plugin-sonarjs": "^2.0.1", 33 | "husky": "^9.1.5", 34 | "lint-staged": "^15.2.9", 35 | "prettier": "^3.3.3" 36 | }, 37 | "lint-staged": { 38 | "*.{js,ts}": [ 39 | "prettier --write", 40 | "eslint --fix" 41 | ] 42 | }, 43 | "module": "src/index.ts" 44 | } 45 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import swagger from '@elysiajs/swagger'; 2 | import { Elysia } from 'elysia'; 3 | 4 | import config from './config'; 5 | import * as db from './config/db'; 6 | import errorPlugin from './plugins/error'; 7 | import loggerPlugin from './plugins/logger'; 8 | import securityPlugin from './plugins/security'; 9 | import authRoutes from './routes/auth'; 10 | import protectedRoutes from './routes/protected'; 11 | 12 | export const app = new Elysia(); 13 | 14 | db.connect(); 15 | 16 | app 17 | .use(loggerPlugin) 18 | .use( 19 | swagger({ 20 | path: '/docs', 21 | documentation: { 22 | info: { 23 | title: 'Bun (🍔) API Starter Docs', 24 | version: config.app.version 25 | } 26 | } 27 | }) 28 | ) 29 | .use(securityPlugin) 30 | .use(errorPlugin) 31 | .get('/', () => ({ 32 | name: config.app.name, 33 | version: config.app.version 34 | })) 35 | .use(authRoutes) 36 | .use(protectedRoutes) 37 | .listen(config.app.port, () => { 38 | console.log(`Environment: ${config.app.env}`); 39 | console.log(`Bun (🍔) API Starter is running at ${app.server?.hostname}:${app.server?.port}`); 40 | }); 41 | -------------------------------------------------------------------------------- /src/config/db.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | import config from '.'; 4 | 5 | const { dbUsername, dbPassword, dbHost, dbPort, dbName } = config.db; 6 | const connectionString = `mongodb://${dbUsername}:${dbPassword}@${dbHost}:${dbPort}/${dbName}`; 7 | 8 | export const connect = async () => { 9 | try { 10 | const res = await mongoose.connect(connectionString, { autoIndex: true }); 11 | 12 | console.log('Info: MongoDB connection successful:', res.connection.name); 13 | } catch (err) { 14 | console.log('Error: Failed to connect MongoDB:', err); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import data from '../../package.json'; 2 | 3 | const isTestEnvironment = Bun.env.NODE_ENV === 'test'; 4 | 5 | export default { 6 | app: { 7 | env: Bun.env.NODE_ENV, 8 | name: data.name, 9 | version: data.version, 10 | host: Bun.env.TEST_APP_HOST || Bun.env.APP_HOST || 'localhost', 11 | port: (isTestEnvironment ? Bun.env.TEST_APP_PORT : Bun.env.APP_PORT) || '8000' 12 | }, 13 | db: { 14 | dbName: Bun.env.DB_NAME!, 15 | dbPassword: Bun.env.DB_PASSWORD!, 16 | dbUsername: Bun.env.DB_USERNAME!, 17 | dbPort: Bun.env.DB_PORT!, 18 | dbHost: Bun.env.DB_HOST! 19 | }, 20 | auth: { 21 | jwt: { 22 | secret: Bun.env.JWT_SECRET!, 23 | expiresIn: Bun.env.JWT_EXPIRES_IN! 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/controllers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'elysia'; 2 | 3 | import { ContextWithJWT } from '../domain/types/extends/ContextWithJWT'; 4 | import SuccessResponse from '../domain/types/generics/SuccessResponse'; 5 | import { User } from '../models/User'; 6 | import { signIn } from '../services/auth'; 7 | import { create } from '../services/user'; 8 | 9 | export const signup = async (context: Context): Promise> => { 10 | const payload = context.body as User; 11 | 12 | await create(payload); 13 | 14 | return { 15 | message: 'Signup successful!' 16 | }; 17 | }; 18 | 19 | export const login = async (context: ContextWithJWT): Promise> => { 20 | const payload = context.body as { email: string; password: string }; 21 | 22 | const user = await signIn(payload); 23 | const token = await context.jwt.sign({ id: user.id }); 24 | 25 | return { 26 | message: 'User logged in successfully!', 27 | data: token 28 | }; 29 | }; 30 | 31 | export const logout = async (): Promise> => { 32 | return { 33 | message: 'User logged out successfully!' 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/controllers/user.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'elysia'; 2 | 3 | import { ContextWithUser } from '../domain/types/extends/ContextWithUser'; 4 | import SuccessResponse from '../domain/types/generics/SuccessResponse'; 5 | import LoggedInUser from '../domain/types/LoggedInUser'; 6 | import type { User } from '../models/User'; 7 | import * as userService from '../services/user'; 8 | 9 | export const me = async (context: ContextWithUser): Promise> => { 10 | return { 11 | message: 'User details fetched successfully!', 12 | data: context.user 13 | }; 14 | }; 15 | 16 | export const create = async (context: Context): Promise> => { 17 | const body = context.body as User; 18 | 19 | const data = await userService.create(body); 20 | 21 | return { 22 | data, 23 | message: 'User created successfully.' 24 | }; 25 | }; 26 | 27 | export const fetchAll = async () => { 28 | const users = await userService.fetchAll(); 29 | 30 | return { 31 | message: 'User fetched successfully.', 32 | data: users 33 | }; 34 | }; 35 | 36 | export const fetchOne = async (context: Context) => { 37 | const { id } = context.params; 38 | const user = await userService.fetchById(id); 39 | 40 | return { 41 | message: 'User fetched successfully.', 42 | data: user 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /src/domain/exceptions/ConflictError.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | export default class ConflictError extends Error { 4 | public status: number; 5 | 6 | constructor(public message: string) { 7 | super(message); 8 | 9 | this.status = StatusCodes.CONFLICT; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/domain/exceptions/MongoServerError.ts: -------------------------------------------------------------------------------- 1 | export default class MongoServerError extends Error { 2 | public code: number; 3 | 4 | constructor(message: string, code: number) { 5 | super(message); 6 | this.name = 'MongoServerError'; 7 | this.code = code; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/domain/exceptions/UnauthorizedError.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | export default class UnauthorizedError extends Error { 4 | public status: number; 5 | 6 | constructor(public message: string) { 7 | super(message); 8 | 9 | this.status = StatusCodes.UNAUTHORIZED; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/domain/types/LoggedInUser.ts: -------------------------------------------------------------------------------- 1 | export default interface LoggedInUser { 2 | readonly _id: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/domain/types/extends/ContextWithJWT.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'elysia'; 2 | 3 | export interface ContextWithJWT extends Context { 4 | jwt: { 5 | readonly sign: (morePayload: Record) => Promise; 6 | readonly verify: (jwt?: string | undefined) => Promise>; 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/types/extends/ContextWithUser.ts: -------------------------------------------------------------------------------- 1 | import LoggedInUser from '../LoggedInUser'; 2 | import { ContextWithJWT } from './ContextWithJWT'; 3 | 4 | export interface ContextWithUser extends ContextWithJWT { 5 | readonly user: LoggedInUser; 6 | } 7 | -------------------------------------------------------------------------------- /src/domain/types/generics/ErrorResponse.ts: -------------------------------------------------------------------------------- 1 | export default interface ErrorResponse { 2 | message: string; 3 | code: Code; 4 | } 5 | -------------------------------------------------------------------------------- /src/domain/types/generics/SuccessResponse.ts: -------------------------------------------------------------------------------- 1 | export default interface SuccessResponse { 2 | message: string; 3 | data?: Data; 4 | } 5 | -------------------------------------------------------------------------------- /src/models/User.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | import UnauthorizedError from '../domain/exceptions/UnauthorizedError'; 4 | 5 | const userSchema = new mongoose.Schema( 6 | { 7 | name: { type: String }, 8 | email: { type: String, required: true, unique: true }, 9 | password: { type: String, required: true, select: false } 10 | }, 11 | { 12 | timestamps: true 13 | } 14 | ); 15 | 16 | // Pre-save middleware to bcrypt the password 17 | userSchema.pre('save', function (next) { 18 | if (this.isModified('password')) { 19 | this.password = Bun.password.hashSync(this.password, { 20 | algorithm: 'bcrypt' 21 | }); 22 | } 23 | 24 | next(); 25 | }); 26 | 27 | userSchema.methods.comparePassword = async function (password: string) { 28 | const user = await User.findById(this._id).select('+password'); 29 | 30 | if (!user) { 31 | throw new UnauthorizedError('User not found'); 32 | } 33 | return Bun.password.verifySync(password, user.password); 34 | }; 35 | 36 | type UserSchema = mongoose.InferSchemaType; 37 | 38 | export interface User extends UserSchema, mongoose.Document { 39 | comparePassword: (password: string) => boolean; 40 | } 41 | 42 | // eslint-disable-next-line sonarjs/no-redeclare 43 | export const User = mongoose.model('User', userSchema); 44 | -------------------------------------------------------------------------------- /src/plugins/authenticate.ts: -------------------------------------------------------------------------------- 1 | import { Elysia } from 'elysia'; 2 | 3 | import UnauthorizedError from '../domain/exceptions/UnauthorizedError'; 4 | 5 | export default (app: Elysia) => 6 | // @ts-expect-error This remains valid after JWT is implemented. 7 | app.derive(async ({ jwt, headers, request }) => { 8 | // TODO: Fix me later. 9 | if (request.url.includes('/docs')) { 10 | return; 11 | } 12 | 13 | const user = await jwt.verify(headers.authorization?.split(' ')[1]); 14 | 15 | if (!user) { 16 | throw new UnauthorizedError('Invalid token!'); 17 | } 18 | 19 | return { 20 | user 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /src/plugins/error.ts: -------------------------------------------------------------------------------- 1 | import { Elysia } from 'elysia'; 2 | import { StatusCodes } from 'http-status-codes'; 3 | 4 | import ConflictError from '../domain/exceptions/ConflictError'; 5 | import UnauthorizedError from '../domain/exceptions/UnauthorizedError'; 6 | import ErrorResponse from '../domain/types/generics/ErrorResponse'; 7 | 8 | export default (app: Elysia) => 9 | app.error({ ConflictError, UnauthorizedError }).onError((handler): ErrorResponse => { 10 | console.error(handler.error?.stack); 11 | 12 | if (handler.error instanceof ConflictError || handler.error instanceof UnauthorizedError) { 13 | handler.set.status = handler.error.status; 14 | 15 | return { 16 | message: handler.error.message, 17 | code: handler.error.status 18 | }; 19 | } 20 | 21 | if (handler.code === 'NOT_FOUND') { 22 | handler.set.status = StatusCodes.NOT_FOUND; 23 | return { 24 | message: 'Not Found!', 25 | code: handler.set.status 26 | }; 27 | } 28 | 29 | if (handler.code === 'VALIDATION') { 30 | handler.set.status = StatusCodes.BAD_REQUEST; 31 | return { 32 | message: 'Bad Request!', 33 | code: handler.set.status 34 | }; 35 | } 36 | 37 | handler.set.status = StatusCodes.SERVICE_UNAVAILABLE; 38 | 39 | return { 40 | message: 'Server Error!', 41 | code: handler.set.status 42 | }; 43 | }); 44 | -------------------------------------------------------------------------------- /src/plugins/logger.ts: -------------------------------------------------------------------------------- 1 | import Elysia from 'elysia'; 2 | import process from 'process'; 3 | import * as yc from 'yoctocolors'; 4 | 5 | import { durationString, methodString } from '../utils/logger'; 6 | 7 | export default (app: Elysia) => 8 | app 9 | .state({ beforeTime: process.hrtime.bigint(), as: 'global' }) 10 | .onRequest((ctx) => { 11 | ctx.store.beforeTime = process.hrtime.bigint(); 12 | }) 13 | .onBeforeHandle({ as: 'global' }, (ctx) => { 14 | ctx.store.beforeTime = process.hrtime.bigint(); 15 | }) 16 | .onAfterHandle({ as: 'global' }, ({ request, store }) => { 17 | const logStr: string[] = []; 18 | 19 | logStr.push(methodString(request.method)); 20 | logStr.push(new URL(request.url).pathname); 21 | 22 | const beforeTime: bigint = store.beforeTime; 23 | 24 | logStr.push(durationString(beforeTime)); 25 | 26 | console.log(logStr.join(' ')); 27 | }) 28 | .onError({ as: 'global' }, ({ request, error, store }) => { 29 | const logStr: string[] = []; 30 | 31 | logStr.push(yc.red(methodString(request.method))); 32 | logStr.push(new URL(request.url).pathname); 33 | logStr.push(yc.red('Error')); 34 | 35 | if ('status' in error) { 36 | logStr.push(String(error.status)); 37 | } 38 | 39 | logStr.push(error.message); 40 | 41 | const beforeTime: bigint = store.beforeTime; 42 | logStr.push(durationString(beforeTime)); 43 | 44 | console.log(logStr.join(' ')); 45 | }); 46 | -------------------------------------------------------------------------------- /src/plugins/security.ts: -------------------------------------------------------------------------------- 1 | import cors from '@elysiajs/cors'; 2 | import jwt from '@elysiajs/jwt'; 3 | import { Elysia, t } from 'elysia'; 4 | 5 | import config from '../config'; 6 | 7 | export default (app: Elysia) => 8 | app.use(cors()).use( 9 | jwt({ 10 | name: 'jwt', 11 | secret: config.auth.jwt.secret, 12 | schema: t.Object({ 13 | id: t.String() 14 | }), 15 | exp: config.auth.jwt.expiresIn 16 | }) 17 | ); 18 | -------------------------------------------------------------------------------- /src/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { Elysia } from 'elysia'; 2 | 3 | import * as authController from '../controllers/auth'; 4 | import { authSchema } from '../schema/authSchema'; 5 | 6 | export default (app: Elysia) => 7 | app.use(authSchema).post('/signup', authController.signup, { body: 'auth' }).post('/login', authController.login, { 8 | body: 'auth' 9 | }); 10 | -------------------------------------------------------------------------------- /src/routes/protected.ts: -------------------------------------------------------------------------------- 1 | import { Elysia } from 'elysia'; 2 | 3 | import * as authController from '../controllers/auth'; 4 | import authPlugin from '../plugins/authenticate'; 5 | import userRoutes from '../routes/user'; 6 | 7 | export default (app: Elysia) => app.use(authPlugin).use(userRoutes).post('/logout', authController.logout); 8 | -------------------------------------------------------------------------------- /src/routes/user.ts: -------------------------------------------------------------------------------- 1 | import { Elysia, t } from 'elysia'; 2 | 3 | import * as userController from '../controllers/user'; 4 | 5 | export default (app: Elysia) => 6 | app 7 | .get('/me', userController.me) 8 | .get('/users/:id', userController.fetchOne) 9 | .post('/users', userController.create, { 10 | body: t.Object({ 11 | name: t.String({ minLength: 1, maxLength: 256 }), 12 | email: t.String({ format: 'email', maxLength: 256 }), 13 | password: t.String({ minLength: 8, maxLength: 256 }) 14 | }) 15 | }) 16 | .get('/users', userController.fetchAll); 17 | -------------------------------------------------------------------------------- /src/schema/authSchema.ts: -------------------------------------------------------------------------------- 1 | import { Elysia, t } from 'elysia'; 2 | 3 | export const authSchema = new Elysia().model({ 4 | auth: t.Object({ 5 | email: t.String({ format: 'email', maxLength: 256 }), 6 | password: t.String({ maxLength: 256 }) 7 | }) 8 | }); 9 | -------------------------------------------------------------------------------- /src/services/auth.ts: -------------------------------------------------------------------------------- 1 | import UnauthorizedError from '../domain/exceptions/UnauthorizedError'; 2 | import { User } from '../models/User'; 3 | 4 | /** 5 | * Signs in a user. 6 | * 7 | * @param {{ email: string; password: string }} payload - The user data to be signed in. 8 | * @param {string} payload.email The email of the user. 9 | * @param {string} payload.password The password of the user. 10 | * @returns {Promise} A promise that resolves to the user that signed in. 11 | */ 12 | export async function signIn(payload: { email: string; password: string }) { 13 | const user = await User.findOne({ email: payload.email }); 14 | 15 | if (!user) { 16 | throw new UnauthorizedError('User not found!'); 17 | } 18 | 19 | const isMatch = user.comparePassword(payload.password); 20 | 21 | if (!isMatch) { 22 | throw new UnauthorizedError('Invalid credentials!'); 23 | } 24 | 25 | return user; 26 | } 27 | -------------------------------------------------------------------------------- /src/services/user.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundError } from 'elysia'; 2 | 3 | import ConflictError from '../domain/exceptions/ConflictError'; 4 | import MongoServerError from '../domain/exceptions/MongoServerError'; 5 | import { User } from '../models/User'; 6 | 7 | /** 8 | * Creates a new user. 9 | * 10 | * @param {User} payload - The user data to be created. 11 | * @returns {Promise} A promise that resolves to the created user. 12 | * @throws {ConflictError} If a user with the same data already exists. 13 | * @throws {Error} If an error occurs while creating the user. 14 | */ 15 | export async function create(payload: User) { 16 | try { 17 | const user = await User.findOne({ email: payload.email }); 18 | 19 | if (user) { 20 | throw new ConflictError('User already exists!'); 21 | } 22 | 23 | return await User.create(payload); 24 | } catch (e) { 25 | const error = e as MongoServerError; 26 | 27 | if (error.name === 'MongoServerError' && error.code === 11000) { 28 | throw new ConflictError('User exists.'); 29 | } 30 | 31 | throw error; 32 | } 33 | } 34 | 35 | /** 36 | * Fetches all users from the database. 37 | * 38 | * @returns {Promise} A promise that resolves to an array of User objects. 39 | */ 40 | export function fetchAll(): Promise { 41 | return User.find(); 42 | } 43 | 44 | /** 45 | * Fetches a user by id. 46 | * 47 | * @param {string} id The id of the user to fetch. 48 | * @returns {Promise} A promise that resolves array User objects. 49 | */ 50 | export async function fetchById(id: string): Promise { 51 | const user = await User.findById(id); 52 | 53 | if (!user) { 54 | throw new NotFoundError('User not found.'); 55 | } 56 | 57 | return user; 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import * as yc from 'yoctocolors'; 2 | 3 | /** 4 | * Returns the duration message. 5 | * 6 | * @param {bigint} beforeTime The time before the request. 7 | * @returns {string} 8 | */ 9 | export function durationString(beforeTime: bigint): string { 10 | const now = process.hrtime.bigint(); 11 | const timeDifference = now - beforeTime; 12 | const nanoseconds = Number(timeDifference); 13 | 14 | let timeMessage: string = ''; 15 | 16 | if (nanoseconds >= 1e9) { 17 | const seconds = (nanoseconds / 1e9).toFixed(2); 18 | timeMessage = `| ${seconds}s`; 19 | } else if (nanoseconds >= 1e6) { 20 | const durationInMilliseconds = (nanoseconds / 1e6).toFixed(0); 21 | 22 | timeMessage = `| ${durationInMilliseconds}ms`; 23 | } else if (nanoseconds >= 1e3) { 24 | const durationInMicroseconds = (nanoseconds / 1e3).toFixed(0); 25 | 26 | timeMessage = `| ${durationInMicroseconds}µs`; 27 | } else { 28 | timeMessage = `| ${nanoseconds}ns`; 29 | } 30 | 31 | return timeMessage; 32 | } 33 | 34 | /** 35 | * Returns the duration message. 36 | * @param {string} method The method. 37 | * @returns {string} 38 | */ 39 | export function methodString(method: string): string { 40 | switch (method) { 41 | case 'GET': 42 | return yc.white('GET'); 43 | 44 | case 'POST': 45 | return yc.yellow('POST'); 46 | 47 | case 'PUT': 48 | return yc.blue('PUT'); 49 | 50 | case 'DELETE': 51 | return yc.red('DELETE'); 52 | 53 | case 'PATCH': 54 | return yc.green('PATCH'); 55 | 56 | case 'OPTIONS': 57 | return yc.gray('OPTIONS'); 58 | 59 | case 'HEAD': 60 | return yc.magenta('HEAD'); 61 | 62 | default: 63 | return method; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/home.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test'; 2 | 3 | import { app } from '../src/app'; 4 | import config from '../src/config'; 5 | import { getRequest } from '.'; 6 | 7 | describe('Elysia', () => { 8 | it('return a response', async () => { 9 | const response = await app.handle(getRequest('/')).then((res: Response) => res.json()); 10 | 11 | expect(response).toMatchObject({ 12 | name: config.app.name, 13 | version: config.app.version 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import config from '../src/config'; 4 | 5 | export const baseUrl = `http://${config.app.host}:${config.app.port}`; 6 | 7 | /** 8 | * Represents a request. 9 | * 10 | * @param {string} route The route. 11 | * @returns {Request} 12 | */ 13 | export function getRequest(route: string) { 14 | const fullPath = path.join(baseUrl, route); 15 | 16 | return new Request(fullPath); 17 | } 18 | 19 | /** 20 | * Represents a POST request. 21 | * 22 | * @param {string} route The route. 23 | * @param {Payload} payload The payload. 24 | * @returns {Request} 25 | */ 26 | export function postRequest>(route: string, payload: Payload) { 27 | const fullPath = path.join(baseUrl, route); 28 | 29 | return new Request(fullPath, { 30 | method: 'POST', 31 | headers: { 32 | 'Content-Type': 'application/json' 33 | }, 34 | body: JSON.stringify(payload) 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["esnext"], 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "bundler", 7 | "moduleDetection": "force", 8 | "allowImportingTsExtensions": true, 9 | "noEmit": true, 10 | "composite": true, 11 | "strict": true, 12 | "downlevelIteration": true, 13 | "skipLibCheck": true, 14 | "esModuleInterop": true, 15 | "allowSyntheticDefaultImports": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "allowJs": true, 18 | "types": ["bun-types"] 19 | } 20 | } 21 | --------------------------------------------------------------------------------