@buntok/core
v2.1.0
Published
Core HTTP framework for Bun, bring your own structure
Maintainers
Readme
@buntok/core
Core HTTP framework untuk Bun — minimalis, cepat, type-safe, dan fleksibel.
Features
- AOT-compiled routing — pipeline middleware di-compile sekali saat boot, zero overhead per-request
- Trie-based router — static routes di-resolve O(1), dynamic routes lewat trie (optional native Zig via FFI)
- Type-safe — full TypeScript support dari route handler sampai validator
- Stage 3 decorators —
@Controller,@Get,@Post,@Use, dll. tanpaexperimentalDecorators - IoC Container — dependency injection dengan circular dependency detection
- Built-in middleware — CORS, compress (gzip/brotli), rate limiter, request ID, response time, helmet (security headers), timeout
- File upload — multipart/form-data parser, per-field validation, custom filename, storage drivers (disk, memory, custom S3/GCS/R2), file deletion
- Zod validation —
zValidatoruntuk body, query, dan params - SSE & WebSocket — native Bun WebSocket, built-in SSE stream
- Utility functions — crypto, password, string, object, number, date, ID generators, network, async helpers
Install
bun add @buntok/core
# atau
npm install @buntok/coreRequirement: Bun >= 1.0.0
Quick Start
import { App } from "@buntok/core";
const app = new App();
app.get("/", (ctx) => {
return ctx.json({ message: "Hello, Buntok!" });
});
app.listen(3000);Port default: 3000, atau dari process.env.PORT.
Table of Contents
- App
- Context
- Routing
- Group Routing
- Middleware
- Built-in Middleware
- Validation (zValidator)
- Decorators
- Error Handling
- TypeScript Types
- IoC Container
- Layered Architecture (Repository, Service, Controller)
- SSE (Server-Sent Events)
- WebSocket
- Cookie Helpers
- Logger
- Health Check
- File Upload
- Crypto Helpers
- Password Helpers
- String Helpers
- Object Helpers
- Number Helpers
- Date Helpers
- ID & Code Generators
- Network Helpers
- Async Helpers
- Security Headers (Helmet)
- Request Timeout
- Testing
App
Membuat Instance
import { App } from "@buntok/core";
const app = new App();API
| Method | Signature | Deskripsi |
|--------|-----------|-----------|
| app.get | (path, ...handlers) | Register route GET |
| app.post | (path, ...handlers) | Register route POST |
| app.put | (path, ...handlers) | Register route PUT |
| app.patch | (path, ...handlers) | Register route PATCH |
| app.delete | (path, ...handlers) | Register route DELETE |
| app.options | (path, ...handlers) | Register route OPTIONS |
| app.all | (path, ...handlers) | Register route semua method |
| app.use | (middleware) | Tambah global middleware |
| app.group | (prefix) | Buat route group |
| app.static | (routePath, directory) | Serve static files |
| app.ws | (path, handler) | Register WebSocket endpoint |
| app.listen | (port?, callback?) | Mulai server |
| app.request | (input, init?) | Dispatch request (untuk testing) |
| app.onError | (handler) | Override global error handler |
| app.notFound | (handler) | Override 404 handler |
| app.set | (key, value) | Simpan value di DI store |
| app.setContainer | (container) | Attach IoC Container |
| app.registerController | (ControllerClass) | Register controller dengan decorator |
| app.validateEnv | (schema) | Validasi env vars dengan Zod schema |
| app.disable | ("x-powered-by") | Nonaktifkan fitur built-in |
| app.enableReusePort | (enabled?) | SO_REUSEPORT untuk multi-process (Linux) |
Listen
// Port dari argumen
app.listen(3000);
// Port dari env (PORT) atau default 1212
app.listen();
// Dengan callback
app.listen(3000, () => {
console.log("Server ready");
});Validate Environment Variables
import { z } from "@buntok/core";
const env = app.validateEnv({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
});
// env.DATABASE_URL, env.JWT_SECRET — fully typed
// Gagal validasi = server tidak jalan + error detail ke consoleContext
ctx adalah satu-satunya argument yang di-pass ke setiap handler. Berisi semua yang dibutuhkan untuk membaca request dan menulis response.
Request
| Property / Method | Tipe | Deskripsi |
|-------------------|------|-----------|
| ctx.request | Request | Raw Bun Request object |
| ctx.params | Record<string, string> | Route parameters (:id, *) |
| ctx.query | Record<string, string> | Parsed query string (lazy, cached) |
| ctx.ip | string | Client IP — respects x-forwarded-for |
| ctx.store | Record<string, any> | Key-value store antar middleware |
| await ctx.body<T>() | Promise<T> | Parse JSON body (cached) |
| await ctx.formData() | Promise<FormData> | Parse multipart form data (cached) |
| ctx.getCookie(name) | string \| undefined | Ambil satu cookie |
| ctx.getCookies() | Record<string, string> | Ambil semua cookies |
| ctx.valid<T>(target) | T | Ambil data yang sudah divalidasi oleh zValidator |
Response
| Method | Return | Deskripsi |
|--------|--------|-----------|
| ctx.json(data, status?) | Response | JSON response |
| ctx.text(text, status?) | Response | Plain text response |
| ctx.html(html, status?) | Response | HTML response |
| ctx.redirect(url, status?) | Response | Redirect (default 302) |
| ctx.status(code) | Response | Empty response dengan status code |
| ctx.success(data?, message?, status?) | Response | Standard success envelope |
| ctx.error(message, status?, details?) | Response | Standard error envelope |
| ctx.paginate(data, total, page, limit) | Response | Offset pagination |
| ctx.cursorPaginate(data, nextCursor) | Response | Cursor/infinite scroll pagination |
| ctx.sse(callback, options?) | Response | Server-Sent Events stream |
Standard Envelope
ctx.success dan ctx.error menggunakan format envelope yang konsisten:
// ctx.success(data, message?, status?)
{ success: true, message: "Success", data: { ... } }
// ctx.error(message, status?, details?)
{ success: false, message: "Not found", details: [...] }
// ctx.paginate(data, total, page, limit)
{
success: true,
message: "Success",
data: [...],
meta: { currentPage: 1, perPage: 20, total: 100, lastPage: 5, hasMore: true }
}
// ctx.cursorPaginate(data, nextCursor)
{
success: true,
message: "Success",
data: [...],
meta: { nextCursor: "abc123", hasMore: true }
}Contoh Penggunaan Context
// Baca params dan query
app.get("/users/:id", (ctx) => {
const { id } = ctx.params;
const { fields } = ctx.query; // ?fields=name,email
return ctx.json({ id, fields });
});
// Parse JSON body
app.post("/users", async (ctx) => {
const body = await ctx.body<{ name: string; email: string }>();
return ctx.success(body, "User created", 201);
});
// Pagination
app.get("/posts", async (ctx) => {
const page = Number(ctx.query.page) || 1;
const posts = await getPosts(page);
const total = await countPosts();
return ctx.paginate(posts, total, page, 20);
});Routing
Route Parameters
// Named param
app.get("/users/:id", (ctx) => {
return ctx.json({ id: ctx.params.id });
});
// Catch-all wildcard
app.get("/files/*", (ctx) => {
return ctx.text(`File: ${ctx.params["*"]}`);
});Route Group
Group mewarisi prefix dan bisa punya middleware sendiri.
const api = app.group("/api/v1");
api.get("/users", listUsers);
api.post("/users", createUser);
// Nested group dengan middleware
const admin = api.group("/admin");
admin.use(adminGuard);
admin.get("/stats", getStats);Static Files
// GET /assets/logo.png -> ./public/logo.png
app.static("/assets", "./public");- Path traversal protection built-in (403 jika path escape directory)
- ETag support untuk conditional requests (304)
Cache-Control: public, max-age=3600
HTTP Methods
app.get("/users", listUsers); // GET
app.post("/users", createUser); // POST
app.put("/users/:id", updateUser); // PUT
app.delete("/users/:id", deleteUser); // DELETE
app.patch("/users/:id", patchUser); // PATCH
app.head("/users", headUsers); // HEAD
app.options("/users", optionsUser); // OPTIONS
app.query("/users", queryUsers); // QUERY (RFC 10008 — safe + idempotent + body)
// Register handler for ALL standard methods at once
app.all("/users", allHandler); // GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONSGroup Routing
app.group() mengembalikan RouterGroup yang punya method-method routing yang sama dengan App.
Dasar
const api = app.group("/api/v1");
// Semua HTTP methods tersedia
api.get("/users", listUsers);
api.post("/users", createUser);
api.put("/users/:id", updateUser);
api.delete("/users/:id", deleteUser);
api.patch("/users/:id", patchUser);
api.head("/users", headUsers);
api.options("/users", optionsUser);
api.query("/users", queryUsers); // QUERY (RFC 10008)
// Register untuk semua method sekaligus
api.all("/health", healthCheck);Nested Groups
const admin = api.group("/admin");
admin.use(adminGuard);
admin.get("/stats", getStats);
admin.delete("/users/:id", forceDelete);WebSocket & Static Files
const ws = app.group("/ws");
ws.ws("/chat", {
open: (ws) => { ws.send("Welcome!"); },
message: (ws, msg) => { ws.send(`Echo: ${msg}`); },
});
ws.static("/assets", "./public");Controller Injection
import { App, Controller, Get, Post } from "@buntok/core";
@Controller("/users")
class UserController {
@Get()
list() { return { users: [] }; }
@Post()
create() { return { created: true }; }
}
const api = app.group("/api/v1");
api.registerController(UserController);
// Terdaftar sebagai:
// GET /api/v1/users
// POST /api/v1/usersGroup prefix digabung dengan controller prefix. Jika controller prefix udah include group prefix (misalnya /api/v1/users), framework otomatis deduplicate.
@Controller("/api/v1/users") // prefix duplikat — otomatis di-handle
class UserController { ... }
const api = app.group("/api/v1");
api.registerController(UserController); // → /api/v1/users ✅Middleware
Signature: (ctx, next) => Response | Promise<Response>
Wajib return next() untuk meneruskan ke handler berikutnya.
// Global middleware
app.use(async (ctx, next) => {
const start = performance.now();
const res = await next();
console.log(`${ctx.request.method} - ${(performance.now() - start).toFixed(2)}ms`);
return res;
});
// Per-route middleware
app.get("/protected", authMiddleware, (ctx) => {
return ctx.json({ data: "secret" });
});
// Group middleware
const api = app.group("/api");
api.use(rateLimiter({ max: 100, windowMs: 60_000 }));
api.get("/users", listUsers);Built-in Middleware
CORS
import { cors } from "@buntok/core";
app.use(cors({
origin: ["http://localhost:3000", "https://myapp.com"],
methods: ["GET", "POST", "PUT", "DELETE"],
headers: ["Content-Type", "Authorization"],
credentials: true,
}));| Option | Tipe | Default |
|--------|------|---------|
| origin | string \| string[] \| (origin: string) => boolean | "*" |
| methods | string[] | ["GET","POST","PUT","DELETE","PATCH","OPTIONS"] |
| headers | string[] | ["Content-Type","Authorization","x-api-key"] |
| credentials | boolean | false |
Compress
import { compress } from "@buntok/core";
app.use(compress({
threshold: 1024, // minimum bytes untuk di-compress (default: 1024)
brotliLevel: 4, // brotli quality 1-11 (default: 4)
}));- Brotli via
node:zlib, Gzip viaBun.gzipSync(native) - Otomatis deteksi
Accept-Encodingdari client - Hanya compress
text/*,application/json,application/javascript,application/xml,image/svg+xml
Rate Limiter
import { rateLimiter, slidingWindowRateLimiter } from "@buntok/core";
// Fixed window
app.use(rateLimiter({
max: 100, // max requests per window (default: 100)
windowMs: 60_000, // window 1 menit (default: 60000)
message: "Too many requests",
statusCode: 429, // default: 429
headers: true, // inject X-RateLimit-* headers (default: true)
}));
// Sliding window (lebih akurat, tidak ada burst di boundary)
app.use(slidingWindowRateLimiter({ max: 100, windowMs: 60_000 }));| Option | Tipe | Default |
|--------|------|---------|
| max | number | 100 |
| windowMs | number | 60000 |
| message | string | "Too many requests, please try again later" |
| statusCode | number | 429 |
| headers | boolean | true |
| keyGenerator | (ctx) => string | IP dari x-forwarded-for / x-real-ip |
| skip | (ctx) => boolean | — |
| store | Map | In-memory |
Request ID
import { requestId, shortId } from "@buntok/core";
app.use(requestId({
header: "x-request-id", // nama header (default: "x-request-id")
storeKey: "requestId", // key di ctx.store (default: "requestId")
}));
app.get("/", (ctx) => {
const id = ctx.store.requestId;
return ctx.json({ requestId: id });
});Re-use ID dari incoming header jika sudah ada.
shortId
Generate short unique ID (8 karakter, URL-safe). Berguna untuk ID ringan tanpa dependency:
import { shortId } from "@buntok/core";
const id = shortId(); // "a3xK9mZp"Response Time
import { responseTime } from "@buntok/core";
app.use(responseTime({
header: "x-response-time", // default: "x-response-time"
format: "ms", // "ms" atau "s" (default: "ms")
}));Validation (zValidator)
zValidator adalah middleware yang memvalidasi request menggunakan Zod schema. Data hasil validasi dibaca via ctx.valid(target).
import { zValidator } from "@buntok/core";
import { z } from "zod";Validasi Body (JSON)
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(17),
});
app.post("/users", zValidator("body", createUserSchema), (ctx) => {
const data = ctx.valid<z.infer<typeof createUserSchema>>("body");
return ctx.json(data, 201);
});Validasi Query
const paginationSchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(10),
});
app.get("/users", zValidator("query", paginationSchema), (ctx) => {
const { page, limit } = ctx.valid("query");
return ctx.json({ page, limit });
});Validasi Params
const idSchema = z.object({
id: z.string().uuid(),
});
app.get("/users/:id", zValidator("params", idSchema), (ctx) => {
const { id } = ctx.valid("params");
return ctx.json({ id });
});Content-Type Lain
// multipart/form-data (file upload)
zValidator("body", schema, { contentType: "multipart/form-data" })
// application/x-www-form-urlencoded
zValidator("body", schema, { contentType: "application/x-www-form-urlencoded" })
// text/plain
zValidator("body", schema, { contentType: "text/plain" })
// application/octet-stream (binary)
zValidator("body", schema, { contentType: "application/octet-stream" })Error response saat validasi gagal:
{
"success": false,
"message": "Validation Failed",
"details": ["body.email: Invalid email"]
}Validation Shortcuts
Untuk kasus sederhana yang hanya perlu validasi satu target, gunakan shorthand:
import { validateBody, validateParams } from "@buntok/core";
// Sama dengan zValidator("body", schema)
app.post("/users", validateBody(createUserSchema), (ctx) => {
const data = ctx.valid("body");
return ctx.json(data, 201);
});
// Sama dengan zValidator("params", schema)
app.get("/users/:id", validateParams(idSchema), (ctx) => {
const { id } = ctx.valid("params");
return ctx.json({ id });
});zResponse (OpenAPI Metadata)
zResponse adalah middleware no-op yang menyisipkan metadata response ke dalam OpenAPI docs (diekspos via app.openApiDocs):
import { zResponse } from "@buntok/core";
app.get("/users",
zResponse(200, z.array(UserSchema), "List of users"),
listUsers
);Decorators
Stage 3 TC39 decorators — tidak perlu experimentalDecorators di tsconfig.
Route Decorators
| Decorator | Method |
|-----------|--------|
| @Get(path) | GET |
| @Post(path) | POST |
| @Put(path) | PUT |
| @Patch(path) | PATCH |
| @Delete(path) | DELETE |
| @Options(path) | OPTIONS |
| @Head(path) | HEAD |
| @All(path) | Semua method |
Middleware & Guard Decorators
import { Use, UseGuard } from "@buntok/core";
// @Use — attach middleware ke route method
@Use(authMiddleware)
@Get("/profile")
async getProfile(ctx: Context) { ... }
// @UseGuard — guard function yang return boolean; false = 403 Forbidden
@UseGuard(async (ctx) => {
return ctx.request.headers.has("x-api-key");
})
@Get("/secret")
async secret(ctx: Context) { ... }DI Decorators
import { Injectable, Inject } from "@buntok/core";
@Injectable() // singleton (default)
@Injectable({ scope: "transient" }) // new instance tiap resolve
// Property injection dari container
@Inject(UserService) private userService: UserService;Contoh Controller Lengkap
import { Controller, Get, Post, Put, Delete, Use } from "@buntok/core";
import type { Context } from "@buntok/core";
import { zValidator } from "@buntok/core";
import { z } from "zod";
const createSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
@Controller("/users")
export class UserController {
@Get("/")
async list(ctx: Context) {
return ctx.json([]);
}
@Get("/:id")
async getById(ctx: Context) {
return ctx.json({ id: ctx.params.id });
}
@Post("/")
@Use(zValidator("body", createSchema))
async create(ctx: Context) {
const data = ctx.valid("body");
return ctx.json(data, 201);
}
@Put("/:id")
async update(ctx: Context) {
return ctx.json({ updated: true });
}
@Delete("/:id")
async remove(ctx: Context) {
return ctx.status(204);
}
}
// Register ke app
app.registerController(UserController);Error Handling
Built-in Error Classes
Throw langsung dari handler — framework auto-catch dan kirim response yang sesuai.
| Class | Status | Deskripsi |
|-------|--------|-----------|
| HttpError | base | Base class untuk semua error |
| BadRequestError | 400 | Request tidak valid |
| UnauthorizedError | 401 | Tidak ada/mode authentication |
| ForbiddenError | 403 | Tidak punya akses |
| NotFoundError | 404 | Resource tidak ditemukan |
| MethodNotAllowedError | 405 | HTTP method tidak diizinkan |
| ConflictError | 409 | Konflik data (unique constraint, dll) |
| UnprocessableEntityError | 422 | Validasi gagal (business logic) |
| TooManyRequestsError | 429 | Rate limit terlampaui |
| InternalServerError | 500 | Error server |
| ServiceUnavailableError | 503 | Service tidak tersedia |
import {
NotFoundError, BadRequestError, UnauthorizedError, ForbiddenError,
ConflictError, UnprocessableEntityError, TooManyRequestsError,
MethodNotAllowedError, InternalServerError, ServiceUnavailableError,
} from "@buntok/core";
app.get("/users/:id", async (ctx) => {
const user = await findUser(ctx.params.id);
if (!user) throw new NotFoundError("User tidak ditemukan");
return ctx.json(user);
});
app.post("/users", async (ctx) => {
const exists = await findUserByEmail(ctx.body.email);
if (exists) throw new ConflictError("Email sudah terdaftar");
// ...
});
app.get("/rate-limited", async (ctx) => {
if (isRateLimited(ctx)) throw new TooManyRequestsError("Coba lagi nanti");
// ...
});Override Error Handler
import { HttpError } from "@buntok/core";
app.onError((err, ctx) => {
if (err instanceof HttpError) {
return ctx.json({ error: err.message }, err.status);
}
console.error(err);
return ctx.json({ error: "Internal Server Error" }, 500);
});Override 404 Handler
app.notFound((ctx) => {
return ctx.json({
error: "Route not found",
path: new URL(ctx.request.url).pathname,
}, 404);
});asyncHandler
Wrapper yang auto-catch error dan return response yang proper. Berguna untuk handler yang tidak throw HttpError:
import { asyncHandler } from "@buntok/core";
app.get("/users/:id", asyncHandler(async (ctx) => {
const user = await findUser(ctx.params.id);
// undefined/null -> 204, HttpError -> status sesuai, Error lain -> 500
return ctx.json(user);
}));TypeScript Types
Semua type yang dibutuhkan untuk typed development sudah di-export.
import type {
// Handler types
Handler,
Middleware,
ErrorHandler,
NotFoundHandler,
// Context
RouteContext,
// Decorator metadata
RouteMeta,
ControllerMeta,
// Upload
UploadedFile,
StorageDriver,
UploadOptions,
UploadFieldConfig,
ParseUploadResult,
// Config
CorsOptions,
HelmetOptions,
CompressOptions,
RateLimiterOptions,
// Utility
ExtractParams,
ZodCtx,
} from "@buntok/core";Contoh Penggunaan
// Typed error handler
const errorHandler: ErrorHandler = (err, ctx) => {
if (err instanceof HttpError) {
return ctx.json({ error: err.message }, err.status);
}
return ctx.json({ error: "Internal Server Error" }, 500);
};
app.onError(errorHandler);
// Typed upload config
const uploadConfig: UploadOptions = {
storage: new LocalDiskStorage("./uploads"),
fields: {
avatar: {
required: true,
maxFileSize: 2 * 1024 * 1024,
allowedMimeTypes: ["image/png", "image/jpeg"],
} satisfies UploadFieldConfig,
},
};
// Extract route params type
type UserParams = ExtractParams<"/users/:id">;
// { id: string }
// Typed CORS config
const corsConfig: CorsOptions = {
origin: ["https://myapp.com"],
methods: ["GET", "POST"],
};IoC Container
Setup
import { Container, Injectable, Inject } from "@buntok/core";
@Injectable()
class UserRepository {
async findAll() { return []; }
}
@Injectable()
class UserService {
@Inject(UserRepository) private repo: UserRepository;
async getAll() {
return this.repo.findAll();
}
}
const container = new Container();
container.registerClass(UserRepository);
container.registerClass(UserService);
app.setContainer(container);API Container
| Method | Deskripsi |
|--------|-----------|
| container.register(token, provider) | Register provider manual |
| container.registerClass(cls, scope?) | Auto-register class provider |
| container.resolve(token) | Resolve instance (dengan DI) |
| container.get(token) | Resolve atau undefined jika tidak ada |
| container.has(token) | Cek apakah token terdaftar |
| container.hasResolved(token) | Cek apakah sudah pernah di-resolve |
| container.clear() | Reset semua providers (berguna di test) |
Provider Types
// Class provider
container.register(MyService, { useClass: MyService, scope: "singleton" });
// Value provider (tidak perlu resolve)
container.register("DB_URL", { useValue: process.env.DATABASE_URL });
// Factory provider
container.register(Database, {
useFactory: (c) => new Database(c.resolve("DB_URL")),
scope: "singleton",
});Scope
| Scope | Deskripsi |
|-------|-----------|
| "singleton" | Satu instance untuk seluruh app (default) |
| "transient" | Instance baru setiap kali di-resolve |
Circular dependency detection built-in — throw
Error: Circular dependency detected: ClassName.
Layered Architecture
@buntok/core menyediakan abstract base classes untuk arsitektur bertingkat (Repository → Service → Controller).
Flow Arsitektur
BaseRepository (@buntok/prisma | @buntok/drizzle | @buntok/typeorm | custom)
↓ extends / inject
BaseService (@buntok/core)
↓ extends / inject
BaseController (@buntok/core)@buntok/core tidak mengikat developer ke satu ORM saja. Tersedia package BaseRepository resmi untuk berbagai ORM populer, atau developer bisa membuat repositori mandiri.
Supported ORM Packages
| Package | ORM | Install | Inisialisasi BaseRepository |
|---------|-----|---------|----------------------------|
| @buntok/prisma | Prisma | bun add @buntok/prisma @prisma/client | super(prisma, "user") |
| @buntok/drizzle | Drizzle | bun add @buntok/drizzle drizzle-orm | super(db, users) |
| @buntok/typeorm | TypeORM | bun add @buntok/typeorm typeorm | super(dataSource, User) |
Contoh BaseRepository Tiap ORM
1. Prisma (@buntok/prisma)
import { PrismaClient } from "@prisma/client";
import { BaseRepository } from "@buntok/prisma";
export class UserRepository extends BaseRepository<User, CreateUserInput, UpdateUserInput> {
constructor(prisma: PrismaClient) {
super(prisma, "user"); // "user" = model name di Prisma schema
}
// Method custom jika perlu
async findByEmail(email: string) {
return this.delegate.findUnique({ where: { email } });
}
}2. Drizzle (@buntok/drizzle)
import { drizzle } from "drizzle-orm/bun-sqlite"; // atau drizzle-orm/node-postgres dll
import { BaseRepository } from "@buntok/drizzle";
import { users } from "./db/schema";
export class UserRepository extends BaseRepository<typeof users, CreateUserInput, UpdateUserInput> {
constructor(db: ReturnType<typeof drizzle>) {
super(db, users); // schema table Drizzle
}
// Method custom jika perlu
async findByEmail(email: string) {
return this.findOne({ email });
}
}3. TypeORM (@buntok/typeorm)
import { DataSource } from "typeorm";
import { BaseRepository } from "@buntok/typeorm";
import { User } from "./entities/user";
export class UserRepository extends BaseRepository<User, CreateUserInput, UpdateUserInput> {
constructor(dataSource: DataSource) {
super(dataSource, User); // Entity class TypeORM
}
// Method custom jika perlu
async findByEmail(email: string) {
return this.findOne({ email });
}
}Contoh Flow Lengkap (End-to-End dengan Prisma)
1. Install Packages
bun add @buntok/core @buntok/prisma @prisma/client
# atau
npm install @buntok/core @buntok/prisma @prisma/client2. Repository Layer (repositories/user.repository.ts)
import { PrismaClient } from "@prisma/client";
import { BaseRepository } from "@buntok/prisma";
export interface User {
id: number;
name: string;
email: string;
}
export interface CreateUserInput {
name: string;
email: string;
}
export interface UpdateUserInput {
name?: string;
email?: string;
}
export class UserRepository extends BaseRepository<User, CreateUserInput, UpdateUserInput> {
constructor(prisma: PrismaClient) {
super(prisma, "user");
}
async findByEmail(email: string) {
return this.delegate.findUnique({ where: { email } });
}
}Catatan:
BaseRepositoryotomatis menyediakan method CRUD:findAll(),findById(id),findOne(where),create(data),update(id, data),delete(id),count(), danexists(where).
3. Service Layer (services/user.service.ts)
import { BaseService, BadRequestError } from "@buntok/core";
import type { User, CreateUserInput, UpdateUserInput, UserRepository } from "../repositories/user.repository";
export class UserService extends BaseService<User, CreateUserInput, UpdateUserInput> {
constructor(private userRepo: UserRepository) {
super(userRepo);
}
// Override untuk menambah validasi / custom business logic
override async create(data: CreateUserInput): Promise<User> {
const exists = await this.userRepo.findByEmail(data.email);
if (exists) throw new BadRequestError("Email sudah terdaftar");
return this.userRepo.create(data);
}
}Catatan:
BaseServicedari@buntok/coreotomatis menyediakan:getAll(),getById(id)(auto-throw NotFoundError jika null),create(data),update(id, data),delete(id), dancount().
4. Controller Layer (controllers/user.controller.ts)
import { BaseController, Controller, Get, Post, Put, Delete } from "@buntok/core";
import type { Context } from "@buntok/core";
import type { User, CreateUserInput, UpdateUserInput } from "../repositories/user.repository";
import type { UserService } from "../services/user.service";
@Controller("/users")
export class UserController extends BaseController<User, CreateUserInput, UpdateUserInput> {
constructor(private userService: UserService) {
super(userService);
}
// Handler CRUD bawaan otomatis terhubung ke UserService:
// - GET /users -> this.getAll
// - GET /users/:id -> this.getById
// - POST /users -> this.create
// - PUT /users/:id -> this.update
// - DELETE /users/:id -> this.delete
// Override method jika perlu custom logic
// Method yang di-override tetap menggunakan decorator yang sama (@Delete("/:id"))
override delete(ctx: Context) {
return this.userService.getById(Number(ctx.params.id)).then((user) => {
if (user.email === "[email protected]") {
return ctx.error("Admin tidak dapat dihapus", 403);
}
return this.userService.delete(Number(ctx.params.id)).then(() => ctx.status(204));
});
}
}5. Register ke App
import { App } from "@buntok/core";
import { PrismaClient } from "@prisma/client";
import { UserRepository } from "./repositories/user.repository";
import { UserService } from "./services/user.service";
import { UserController } from "./controllers/user.controller";
const app = new App();
const prisma = new PrismaClient();
const userRepo = new UserRepository(prisma);
const userService = new UserService(userRepo);
const userController = new UserController(userService);
// Register controller (semua routes otomatis terdaftar)
app.registerController(userController);
app.listen(3000);Route otomatis terdaftar:
GET /users→getAllGET /users/:id→getByIdPOST /users→createPUT /users/:id→updateDELETE /users/:id→delete
Database Connection
@buntok/core tidak menyediakan database client. Developer mengelola koneksi sendiri dan pass ke Repository.
Pattern: Manual Setup
import { App } from "@buntok/core";
import { PrismaClient } from "@prisma/client";
import { UserRepository } from "./repositories/user.repository";
import { UserService } from "./services/user.service";
import { UserController } from "./controllers/user.controller";
// 1. Create database client
const prisma = new PrismaClient();
// 2. Pass ke Repository
const userRepo = new UserRepository(prisma);
// 3. Repository → Service → Controller
const userService = new UserService(userRepo);
const userController = new UserController(userService);
// 4. Register
const app = new App();
app.registerController(userController);
app.listen(3000);Pattern: Factory Function
// db.ts
export function createRepositories() {
const prisma = new PrismaClient();
return {
userRepo: new UserRepository(prisma),
postRepo: new PostRepository(prisma),
};
}
// index.ts
const { userRepo, postRepo } = createRepositories();
const app = new App();
app.registerController(new UserController(new UserService(userRepo)));
app.registerController(new PostController(new PostService(postRepo)));Package ORM
| ORM | Package |
|-----|---------|
| Prisma | @buntok/prisma |
| Drizzle | @buntok/drizzle |
| TypeORM | @buntok/typeorm |
Implementasi Repository Mandiri (Custom ORM / Raw SQL)
Jika developer menggunakan ORM lain atau query SQL langsung, cukup buat class repository yang mengimplementasikan kontrak interface IRepository:
interface IRepository<T, CreateInput, UpdateInput> {
findAll(): Promise<T[]>;
findById(id: number | string): Promise<T | null>;
create(data: CreateInput): Promise<T>;
update(id: number | string, data: UpdateInput): Promise<T>;
delete(id: number | string): Promise<void | T>;
count(): Promise<number>;
}SSE (Server-Sent Events)
app.get("/events", (ctx) => {
return ctx.sse(async (sse) => {
// Kirim event
sse.send({ event: "message", data: "Hello!" });
// Kirim data object (auto JSON.stringify)
sse.sendEvent("update", { count: 42 });
// Dengan ID (untuk client resumption via Last-Event-ID)
sse.sendWithId("msg-001", "payload");
// Callback saat client disconnect
sse.onClose(() => {
clearInterval(timer);
});
// Cek status koneksi
if (sse.isConnected) { ... }
// Tutup stream manual
// sse.close();
});
});SSE Options
| Option | Tipe | Default | Deskripsi |
|--------|------|---------|-----------|
| heartbeatInterval | number | 30000 | Interval heartbeat dalam ms |
| sendInitial | boolean | true | Kirim event "connected" saat pertama |
| initialEvent | string | "connected" | Nama event pertama |
| retry | number | — | Reconnect interval yang dikirim ke client (ms) |
createSSE (Alternatif)
createSSE adalah helper untuk membuat SSE stream secara manual tanpa ctx.sse():
import { createSSE } from "@buntok/core";
app.get("/events", (ctx) => {
const sse = createSSE(ctx.request, {
heartbeatInterval: 30000,
retry: 5000,
});
// Kirim event
sse.send({ event: "message", data: "Hello!" });
// Tutup stream
sse.close();
return sse.response();
});WebSocket
Native Bun WebSocket — tanpa polyfill, tanpa abstraction layer.
app.ws("/chat", {
open(ws) {
ws.subscribe("room-general");
ws.send("Welcome!");
},
message(ws, message) {
ws.publish("room-general", message);
},
close(ws, code, reason) {
console.log("Disconnected", code, reason);
},
drain(ws) {
// Buffer siap ditulis lagi
},
});
// Broadcast dari luar handler
app.server?.publish("room-general", JSON.stringify({ type: "announcement" }));app.server adalah Bun Server instance, tersedia setelah app.listen() dipanggil.
Cookie Helpers
import { getCookie, getCookies, setCookie, deleteCookie, parseCookies, serializeCookie } from "@buntok/core";
// Via ctx (paling praktis)
const token = ctx.getCookie("token");
const all = ctx.getCookies();
// Set cookie — returns new Response dengan Set-Cookie header
const response = setCookie(ctx.json({ ok: true }), "token", "abc123", {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 86_400,
path: "/",
});
// Delete cookie
const response = deleteCookie(ctx.json({ ok: true }), "token");
// Parse manual dari header string
const cookies = parseCookies(request.headers.get("Cookie") ?? "");
// Serialize manual
const header = serializeCookie("session", "xyz", { httpOnly: true, maxAge: 3600 });Cookie Options
| Option | Tipe | Deskripsi |
|--------|------|-----------|
| httpOnly | boolean | Blokir akses dari JavaScript |
| secure | boolean | Hanya via HTTPS |
| sameSite | "lax" \| "strict" \| "none" | CSRF protection |
| maxAge | number | Umur dalam detik |
| expires | Date | Tanggal kedaluwarsa absolut |
| path | string | Path scope (default: "/") |
| domain | string | Domain scope |
| partitioned | boolean | Partitioned cookie (CHIPS) |
Logger
import { logger, Logger, LogLevel } from "@buntok/core";
// logger adalah singleton default
logger.info("Server started", { port: 3000 });
logger.warn("High memory usage", { mb: 512 });
logger.error("DB connection failed", { host: "localhost" });
logger.debug("Query executed", { sql: "SELECT ..." });Konfigurasi
const myLogger = new Logger({
level: LogLevel.DEBUG, // filter level minimum
format: "json", // "text" (dev) atau "json" (prod)
logRequests: true, // log setiap incoming request (default: true)
});Default behavior:
- Development (
NODE_ENV!="production"): formattext, levelINFO - Production: format
json, levelWARN
Log ke File
LOG_DIR=./logs bun run app.ts
# Menulis ke ./logs/app-YYYY-MM-DD.logLog Levels
| Level | Nilai | Termasuk |
|-------|-------|---------|
| LogLevel.DEBUG | 0 | Debug, info, warn, error |
| LogLevel.INFO | 1 | Info, warn, error |
| LogLevel.WARN | 2 | Warn, error |
| LogLevel.ERROR | 3 | Error saja |
Env Variables
| Var | Efek |
|-----|------|
| NODE_ENV=production | format JSON, level WARN |
| LOG_DIR=./logs | tulis log ke file |
| LOG_REQUESTS=false | nonaktifkan request logging |
Health Check
import { healthCheck, createDatabaseCheck, createHealthCheck } from "@buntok/core";
// Simple
healthCheck(app, {
path: "/health", // default: "/health"
version: "1.0.0", // opsional
includeUptime: true, // default: true
});
// GET /health -> { status: "healthy", uptime: 123, version: "1.0.0", timestamp: "..." }
// Dengan database check
healthCheck(app, {
check: createDatabaseCheck(async () => {
await db.query("SELECT 1");
return true; // false = unhealthy
}),
});
// Multiple service checks
healthCheck(app, {
check: createHealthCheck([
{ name: "database", check: () => db.ping() },
{ name: "redis", check: () => redis.ping() },
{ name: "storage", check: () => s3.headBucket() },
]),
});
// Response 200 jika semua sehat, 503 jika ada yang unhealthyFile Upload
Buntok menyediakan upload handler untuk multipart/form-data — mendukung validasi per-field, custom filename, storage driver, dan file deletion.
import {
uploader, parseUploads, deleteUploadedFile,
LocalDiskStorage, MemoryStorage,
} from "@buntok/core";Upload Options
| Option | Tipe | Default | Deskripsi |
|--------|------|---------|-----------|
| storage | StorageDriver | — | wajib — LocalDiskStorage, MemoryStorage, atau custom driver |
| filename | (original, file) => string | original-uuid.ext | Global default filename generator |
| fields | Record<string, UploadFieldConfig> | — | Whitelist & validasi per-field |
Note:
maxFileSizedanallowedMimeTypessekarang di level per-field, bukan global.
Upload Field Config
| Option | Tipe | Default | Deskripsi |
|--------|------|---------|-----------|
| required | boolean | false | Wajib ada atau tidak |
| maxFileSize | number | — | Maksimal ukuran file (bytes) |
| allowedMimeTypes | string[] | — | MIME types yang diizinkan |
| filename | (original, file) => string | — | Custom filename generator untuk field ini |
Built-in Storage Drivers
| Driver | Penjelasan |
|--------|-----------|
| LocalDiskStorage(dir) | Simpan file ke direktori di disk, auto-create folder |
| MemoryStorage | Simpan file di memory sebagai ArrayBuffer |
Middleware Approach (Recommended)
uploader() adalah middleware yang auto-parse dan validasi. Jika gagal, langsung return 400.
app.post("/upload",
uploader({
storage: new LocalDiskStorage("./uploads"),
fields: {
avatar: {
required: true,
maxFileSize: 2 * 1024 * 1024, // 2MB
allowedMimeTypes: ["image/png", "image/jpeg"],
},
document: {
required: true,
maxFileSize: 10 * 1024 * 1024, // 10MB
allowedMimeTypes: ["application/pdf"],
},
},
}),
async (ctx) => {
const files = ctx.store.files; // UploadedFile[]
const fileMap = ctx.store.fileMap; // Record<string, UploadedFile | UploadedFile[]>
const fields = ctx.store.fields; // Record<string, string> (text fields)
return ctx.json({ uploaded: files.length });
}
);Manual Approach
Panggil parseUploads() langsung untuk kontrol penuh. Jika validasi gagal, otomatis throw BadRequestError (400).
app.post("/upload", asyncHandler(async (ctx) => {
// throw BadRequestError jika validasi gagal (size, mime, required)
const result = await parseUploads(ctx, {
storage: new LocalDiskStorage("./uploads"),
filename: (_, file) => `avatar-${Date.now()}.${file.type}`,
fields: {
avatar: {
required: true,
maxFileSize: 2 * 1024 * 1024,
allowedMimeTypes: ["image/png", "image/jpeg"],
},
},
});
// result hanya berisi data sukses — error sudah throw di atas
return ctx.json({ files: result.files });
}));Custom Filename
// Global default
const result = await parseUploads(ctx, {
storage: new LocalDiskStorage("./uploads"),
filename: (originalName, file) => {
const ext = originalName.split(".").pop();
return `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
},
fields: { avatar: { required: true } },
});
// Per-field override
const result = await parseUploads(ctx, {
storage: new LocalDiskStorage("./uploads"),
fields: {
avatar: {
required: true,
filename: (name) => `avatars/${Date.now()}-${name}`,
},
document: {
required: true,
filename: (name) => `docs/${crypto.randomUUID()}-${name}`,
},
},
});Auto-extension: Jika filename callback tidak return extension, framework otomatis tambah dari file asli.
Multi-Upload (Same Field Name)
Jika field name sama untuk beberapa file, fileMap otomatis jadi array:
// Client: <input type="file" name="photos" multiple>
app.post("/gallery",
uploader({
storage: new LocalDiskStorage("./gallery"),
fields: {
photos: { maxFileSize: 10 * 1024 * 1024 },
},
}),
async (ctx) => {
const photos = ctx.store.fileMap["photos"]; // UploadedFile[]
return ctx.json({ count: photos.length });
}
);UploadedFile Object
{
originalName: string; // nama file asli dari client
filename: string; // nama file setelah di-generate
size: number; // ukuran dalam bytes
type: string; // MIME type (e.g. "image/png")
buffer?: ArrayBuffer; // ada jika MemoryStorage
path?: string; // ada jika LocalDiskStorage (absolute path)
}Custom Storage Driver
Untuk connect ke provider lain (AWS S3, Google Cloud Storage, Cloudflare R2, dll), implement StorageDriver interface:
import type { StorageDriver, UploadedFile } from "@buntok/core";
class S3Storage implements StorageDriver {
constructor(private bucket: string, private region: string) {}
async handleFile(file: File, filename: string): Promise<UploadedFile> {
const buffer = await file.arrayBuffer();
// Upload ke S3 (contoh pakai AWS SDK)
await s3.send(new PutObjectCommand({
Bucket: this.bucket,
Key: filename,
Body: new Uint8Array(buffer),
ContentType: file.type,
}));
return {
originalName: file.name,
filename,
size: file.size,
type: file.type,
};
}
async deleteFile(filename: string): Promise<boolean> {
try {
await s3.send(new DeleteObjectCommand({
Bucket: this.bucket,
Key: filename,
}));
return true;
} catch {
return false;
}
}
}
// Gunakan
app.post("/upload", asyncHandler(async (ctx) => {
const result = await parseUploads(ctx, {
storage: new S3Storage("my-bucket", "us-east-1"),
fields: {
avatar: { required: true, maxFileSize: 2 * 1024 * 1024 },
},
});
return ctx.json({ files: result.files });
}));Cloudflare R2
class R2Storage implements StorageDriver {
constructor(private bucket: R2Bucket) {}
async handleFile(file: File, filename: string): Promise<UploadedFile> {
await this.bucket.put(filename, file, {
httpMetadata: { contentType: file.type },
});
return {
originalName: file.name,
filename,
size: file.size,
type: file.type,
};
}
async deleteFile(filename: string): Promise<boolean> {
await this.bucket.delete(filename);
return true;
}
}File Deletion
Hapus file yang sudah di-upload menggunakan deleteUploadedFile:
import { deleteUploadedFile, LocalDiskStorage } from "@buntok/core";
const storage = new LocalDiskStorage("./uploads");
// Upload
const result = await parseUploads(ctx, {
storage,
fields: { avatar: { required: true } },
});
const avatar = result.fileMap.avatar;
// Hapus file
const deleted = await deleteUploadedFile(storage, avatar);
// deleted = true jika berhasil, false jika gagalAtau langsung akses storage.deleteFile():
// Hapus berdasarkan path
await storage.deleteFile("/absolute/path/to/file.jpg");
// Hapus dari UploadedFile
if (storage.deleteFile && avatar.path) {
await storage.deleteFile(avatar.path);
}Note:
deleteFile()adalah method optional padaStorageDriver. Jika storage tidak implement (misal MemoryStorage), returnfalse.
Crypto Helpers
Hash, random, dan encryption — semua tanpa dependency, berbasis WebCrypto API.
import {
hash, sha256, sha512, md5, hmac, hashVerify,
randomBytes, randomHex, randomAlphaNumeric, randomToken,
encrypt, decrypt,
} from "@buntok/core";Hashing
// Hash dengan algoritma pilihan (default: SHA-256)
const digest = await hash("password", "SHA-256");
// => "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
const d2 = await sha256("password"); // shorthand
const d3 = await sha512("password");
// MD5 — untuk legacy/cache keys, BUKAN untuk keamanan
const m = await md5("hello");
// => "5d41402abc4b2a76b9719d911017c592"
// HMAC
const signature = await hmac("message", "secret-key", "SHA-256");
// Timing-safe verify
const valid = await hashVerify("password", digest); // true/falseRandom
randomBytes(16); // Uint8Array(16)
randomHex(32); // "a1b2c3..." (32 hex chars)
randomAlphaNumeric(16); // "Kx7Gm2pL9qR4nB8w"
randomToken(32); // URL-safe base64 tokenEncrypt / Decrypt (AES-256-GCM)
const { ciphertext, iv } = await encrypt("secret data", "my-key");
const plain = await decrypt(ciphertext, "my-key", iv);
// => "secret data"
// Dengan custom IV (opsional)
const iv = crypto.getRandomValues(new Uint8Array(12));
const { ciphertext } = await encrypt("data", "key", iv);Password Helpers
Hash & verify password menggunakan PBKDF2-SHA-256.
import { hashPassword, verifyPassword } from "@buntok/core";// Hash password
const hashed = await hashPassword("mypassword");
// => "100000:salt:hash" (semua hex)
// Verify password
const valid = await verifyPassword("mypassword", hashed); // true
const invalid = await verifyPassword("wrong", hashed); // falseString Helpers
import { slugify, truncate, capitalize, camelCase, snakeCase, kebabCase } from "@buntok/core";slugify("Hello World!"); // "hello-world"
truncate("Lorem ipsum dolor", 10); // "Lorem ipsu..."
capitalize("hello"); // "Hello"
camelCase("hello-world"); // "helloWorld"
snakeCase("helloWorld"); // "hello_world"
kebabCase("helloWorld"); // "hello-world"Object Helpers
import {
pick, omit, groupBy, uniq, flatten, chunk,
deepMerge, flattenObject,
} from "@buntok/core";// Pick & Omit
pick({ a: 1, b: 2, c: 3 }, ["a", "c"]); // { a: 1, c: 3 }
omit({ a: 1, b: 2, c: 3 }, ["b"]); // { a: 1, c: 3 }
// Group & Unique
groupBy([{ type: "a" }, { type: "b" }, { type: "a" }], "type");
// => { a: [{ type: "a" }, ...], b: [{ type: "b" }] }
uniq([1, 2, 2, 3]); // [1, 2, 3]
// Flatten & Chunk
flatten([[1, 2], [3, [4, 5]]]); // [1, 2, 3, 4, 5]
chunk([1, 2, 3, 4, 5], 2); // [[1, 2], [3, 4], [5]]
// Deep Merge
deepMerge({ a: 1, b: { c: 2 } }, { b: { d: 3 } });
// => { a: 1, b: { c: 2, d: 3 } }
// Flatten Object (dot notation)
flattenObject({ a: { b: { c: 1 } } });
// => { "a.b.c": 1 }Number Helpers
import { clamp, random, randomFloat, formatNumber, formatBytes, formatCurrency } from "@buntok/core";clamp(15, 0, 10); // 10
clamp(-5, 0, 10); // 0
random(1, 100); // 42 (integer)
randomFloat(1.5, 3.5); // 2.731
formatNumber(1000000); // "1,000,000"
formatBytes(1536); // "1.5 KB"
formatBytes(1048576); // "1 MB"
formatCurrency(1000); // "$1,000.00"
formatCurrency(1000, "EUR", "id-ID"); // "€1.000"Date Helpers
import { formatDate, timeAgo } from "@buntok/core";formatDate(new Date()); // "2024-01-15T10:30:00.000Z"
timeAgo(new Date(Date.now() - 180000)); // "3 minutes ago"
timeAgo(new Date(Date.now() - 7200000)); // "2 hours ago"
timeAgo(new Date(Date.now() - 86400000)); // "1 day ago"ID & Code Generators
import { generateCode, nanoid, ulid, resetCounter } from "@buntok/core";// Sequential code dengan prefix
generateCode("T"); // "T0001"
generateCode("T"); // "T0002"
generateCode("INV"); // "INV0001"
generateCode("ORD", 42); // "ORD0042"
// Reset counter
resetCounter("T");
generateCode("T"); // "T0001" (back to start)
// Nanoid (URL-safe unique ID)
nanoid(); // "V1StGXR8_Z5jdHi6B-myT" (21 chars)
nanoid(10); // "V1StGXR8_Z5" (10 chars)
// ULID (time-ordered, sortable by creation time)
ulid(); // "01ARZ3NDEKTSV4RRFFQ69G5FAV"Network Helpers
import { getClientIP, isPrivateIP, parseUserAgent } from "@buntok/core";// Client IP (respects X-Forwarded-For, X-Real-IP)
const ip = getClientIP(request);
// Check private/reserved IP
isPrivateIP("192.168.1.1"); // true
isPrivateIP("8.8.8.8"); // false
// Parse User-Agent
const ua = parseUserAgent(request);
// => { browser: "Chrome", os: "Windows", device: "Desktop" }Async Helpers
import { delay, retry } from "@buntok/core";// Delay / sleep
await delay(1000); // tunggu 1 detik
// Retry dengan backoff
const data = await retry(
() => fetch("https://api.example.com/data").then(r => r.json()),
{
retries: 3, // maksimal 3 retry (total 4 attempts)
delay: 1000, // mulai dari 1 detik
backoff: "exponential", // 1s → 2s → 4s
}
);
// Retry dengan custom error filter
await retry(
() => riskyOperation(),
{
retries: 5,
delay: 500,
onError: (err, attempt) => {
// return false untuk stop retry
return !(err instanceof RateLimitError);
},
}
);Security Headers (Helmet)
import { helmet } from "@buntok/core";Menambahkan security headers standar ke semua response:
| Header | Default |
|--------|---------|
| X-Content-Type-Options | nosniff |
| X-Frame-Options | DENY |
| X-XSS-Protection | 0 |
| Referrer-Policy | no-referrer |
| Strict-Transport-Security | max-age=31536000; includeSubDomains |
| X-DNS-Prefetch-Control | on |
| Permissions-Policy | camera=(), microphone=(), geolocation=() |
// Default — semua standard headers
app.use(helmet());
// Custom options
app.use(helmet({
frameOptions: "SAMEORIGIN",
hsts: "max-age=63072000",
additionalHeaders: { "X-Custom-Header": "value" },
}));Request Timeout
import { timeout } from "@buntok/core";// Auto-return 408 jika handler lebih dari 5 detik
app.get("/slow", timeout(5000), async (ctx) => {
await longOperation();
return ctx.json({ ok: true });
});
// Custom timeout dan pesan error
app.post("/upload", timeout(30000, "Upload timed out"), async (ctx) => {
// ...
});TimeoutError bisa di-catch untuk logging:
import { timeout, TimeoutError } from "@buntok/core";
app.get("/data", timeout(5000), async (ctx) => {
try {
const data = await fetchData();
return ctx.json(data);
} catch (err) {
if (err instanceof TimeoutError) {
console.log(`Request timed out after ${err.timeoutMs}ms`);
}
throw err;
}
});Testing
app.request() mendispatch request tanpa membuka port nyata:
import { App } from "@buntok/core";
const app = new App();
app.get("/ping", (ctx) => ctx.json({ pong: true }));
// Test tanpa server
const res = await app.request("/ping");
const data = await res.json();
console.assert(res.status === 200);
console.assert(data.pong === true);
// Dengan method dan body
const res2 = await app.request("/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Budi", email: "[email protected]" }),
});Kompatibel dengan Bun test runner:
import { describe, test, expect, beforeAll } from "bun:test";
import { App } from "@buntok/core";
const app = new App();
app.get("/users", (ctx) => ctx.json([]));
describe("Users API", () => {
test("GET /users returns 200", async () => {
const res = await app.request("/users");
expect(res.status).toBe(200);
expect(await res.json()).toEqual([]);
});
});License
MIT
