ما هو Bun؟
Bun هو بيئة تشغيل JavaScript جديدة تمامًا وبديل لـ Node.js. يُروَّج له كبيئة تشغيل سريعة توفّر دعمًا جاهزًا لـ TypeScript، واستخدام وحدات Node.js وNPM الأصلية، بالإضافة إلى نظام بيئي موحّد أكثر من الأدوات.
إعداد Bun
في الوقت الحالي، لا يُدعم Bun إلا على أنظمة Unix. إذا كنت مستخدم Windows، يمكنك إعداد Bun على WSL:
- MacOS/Linux: https://bun.sh/docs/installation
- Windows (باستخدام WSL): https://www.youtube.com/watch?v=aNL3gXW0ZuM
- NPM: npm i -g bun (لا يعمل حاليًا على Windows)
في هذه المدونة، سنستخدم Elysia، إطار عمل الواجهات البرمجية لـ Bun (شبيه إلى حد ما بـ Express لـ Node.js). ورغم أن Elysia مستوحى من Express، إلا أنه يقدّم ابتكارات مثيرة للاهتمام على الصيغة.
تهيئة مشروع
هناك طريقتان لإعداد مشروع Elysia على جهازك:
- يدويًا
- باستخدام قالب
يدويًا
يدور هذا النهج حول قيامك بإعداد الاعتماديات بنفسك، بدءًا بتهيئة مشروع Bun:
> bun init -y
// then it prints the following
Done! A package.json file was saved in the current directory.
+ index.ts
+ .gitignore
+ tsconfig.json (for editor auto-complete)
+ README.md
To get started, run:
bun run index.tsثم ثبّت Elysia وجميع الاعتماديات المطلوبة:
> bun add elysia
> bun add bun-types -d // dev dependenciesبعدها أضف سكريبتات في ملف package.json لتشغيل المشروع:
"scripts": {
"dev": "bun run --watch index.ts" // enables hot reload
},بعدها يمكنك تشغيل التطبيق باستخدام سكريبت bun dev والمضي قدمًا في إنشاء تطبيق أحلامك.
القالب
يمكنك تخطي كل هذه الخطوات إذا استخدمت قالبًا جاهزًا. هكذا تُعدّ Elysia من القالب:
> bun create elysia my-appما ينبغي أن يولّد تطبيق Elysia جاهزًا للاستخدام.
موجّه الواجهة البرمجية (API Router)
يجب أن تكون صياغة Elysia.js مألوفة لأي شخص استخدم Express.js من قبل. عند استيراد Elysia، تنشئ نسخة منه وتُسندها إلى متغيّر جديد (app).
import { Elysia, t } from 'elysia';
const app = new Elysia();ستستخدم app لضبط دوال المسارات (نقاط النهاية):
- app.get (GET)
- app.post (POST)
- app.put (PUT)
- app.delete (DELETE)
app.get(...) نقطة النهاية (Endpoint)
كل نقطة نهاية هي دالة تحتوي على معاملين على الأقل: مسار URI، تليه دالة استدعاء (callback) قد تحتوي أو لا تحتوي على معامل معالِج سياق (context handler).
app.get('URI PATH', () => {
// CALLBACK
});app.get('/', async () => {
return 'Hello World'
}); Async
في عالم JavaScript الحديث، كل نقطة نهاية غير متزامنة (asynchronous) افتراضيًا. إذا شعرت بالحاجة، يمكنك إضافة الكلمة المفتاحية async قبل دالة الاستدعاء المعالِجة لاستخدام await داخل دالة الاستدعاء.
app.get('/', async () => {
const users = await User.find({});
return users;
}); كشف المنفذ (Port)
تمامًا كما في تطبيق Express.js، تحتاج لضبط منفذ سيستخدمه Elysia لإطلاق خادم.
const app = new Elysia();
app.get('/', async () => {
return 'Hello World'
});
app.listen(PORT, () => {
console.log(`🦊 Elysia is running at ${app.server?.hostname}:${PORT}`);
});اكتمل إعداد الخادم. إذا زرت التطبيق على منفذ محدد، مثل localhost:3000، يجب أن ترى 'Hello World' معروضة في المتصفح.
المجموعات (Groups) والمتحكمات (Controllers)
إذا كان لديك عدة واجهات برمجية تندرج تحت نفس الفئة، مثل:
app.get('/user/get', ...)
app.post('/user/create', ...)
app.put('/user/update', ...)يمكنك الاستفادة من المجموعات (groups) لتنظيمها بشكل أفضل وتجنّب تكرار نفسك.
app.group('/user', app =>
app.get('/get', ...)
app.post('/create', ...)
app.put('/update', ...)
)الآن ستحمل كل نقطة نهاية ضمن هذه المجموعة (user) نفس البادئة.
المتحكمات (Controllers)
إذا أردت تقسيم الميزات إلى متحكمات، يمكنك فعل ذلك. أولًا، أنشئ دالة تغلّف المسارات في ملف معين:
export const usersController = (app: Elysia) =>
app.get('/users'...);
app.post('/users'...)ثم صدّر هذه الدالة واحقنها في الموجّه الرئيسي باستخدام دالة الوسيط use.
app.get('/', () => 'Hello Bun.js!')
app.use(usersController)الآن قسّمت المسارات لكل متحكم.
متغيرات البيئة
يحتوي Bun على دعم مدمج لقراءة متغيرات البيئة عبر ملف .env. فقط أنشئ ملف .env في مشروعك ودع Bun يتولى الباقي.
لا يزال بإمكانك استخدام process.env التقليدي لقراءة متغيّر البيئة:
// ...some Bun code
const PORT = process.env.PORT || 3000;
سياق المسار (Route Context)
في Elysia، لم يعد لديك كائنا الطلب (request) والاستجابة (response) بشكل منفصل في المسار. بدلًا من ذلك، يُدمج الاثنان في كائن معالِج سياق واحد (Context Handler) يُستخدم لقراءة البيانات الواردة وإرسال استجابة مناسبة.
app.get('/', (handler: Elysia.Handler) => {
return 'Hello World'
});يمكنك الوصول إلى خصائص محددة من الطلب:
- الرابط: handler.request.url
- الطريقة: handler.request.method
- كائن الاستعلام: handler.query
- كائن المعاملات: handler.params
- كائن المحتوى: handler.body
- كائن الترويسات: handler.request.headers
تزيين الطلب (Decorate Request)
يمكنك إضافة ميزات إضافية إلى معالِج الطلب باستخدام دالة الموجّه decorate:
app.decorate('propertyName', variable)بعدها في دالة الاستدعاء، يمكنك الاستفادة من القيمة المُزيَّنة:
app.decorate('propertyName', variable)
app.get('/', (handler: Elysia.Handler) => {
app.propertyName // ...
})من حالات الاستخدام الشائعة لهذا حقن نسخة من قاعدة البيانات في الموجّه لديك حتى تتمكن جميع المسارات من الوصول إليها دون الحاجة لإعادة إنشائها.
إعادة الاستجابة (Return Response)
لإعادة الاستجابة إلى العميل، ببساطة استخدم الكلمة المفتاحية return المدمجة في اللغة. يمكن للمسار إعادة أي نوع من الاستجابة وسيضبط نوع المحتوى الصحيح تلقائيًا:
// String
app.get('/', (handler: Elysia.Handler) => {
return 'Hello World!'
});
// Object
app.get('/', (handler: Elysia.Handler) => {
return { message: 'Hello' }
});
// Array
app.get('/', (handler: Elysia.Handler) => {
return [{ ... }]
});يمكنك حتى إعادة HTML باستخدام إضافة @elysiajs/html (التي تحتاج لتثبيتها).
ترويسات الاستجابة ورمز الحالة
لكن ماذا عن إعادة رمز حالة مخصص؟ لهذا، يمكنك استخدام علامة set على معالِج السياق التي ستُعدّل كائن الاستجابة المرسَل إلى العميل.
app.post('/', async (handler: Elysia.Handler) => {
const data = 'some response'
// Setting custom response headers
handler.set.headers = {
'X-Authorization': accessToken,
};
// Setting custom status code
handler.set.status = 201;
return data;
});الآن ستحمل بيانات الاستجابة رمز حالة 201 وترويسة مخصصة إلى جانب نص/كائن الاستجابة.
بدلًا من ذلك، يمكنك استخدام صنف Response المدمج في JavaScript:
return new Response("Your Message", {
status: 418,
headers: { ... }
}); التحقق من صحة الواجهة البرمجية باستخدام Guards
يمكنك حماية المسارات والتحقق من صحة البيانات الواردة باستخدام Guards.
app.guard({
body: t.Object({
username: t.String(),
email: t.String(),
password: t.String()
})
// This route is protected by the Guard above
}, (app: Elysia) =>
app.post('/', async (handler: Elysia.Handler) => { ... }))هنا، أتحقق من صحة محتوى الطلب المرسَل إلى هذا المسار للتأكد من أن المستهلك يزوّد جميع المعاملات الثلاثة المطلوبة. إذا فشل أي من الشروط، سيرمي الـ guard خطأ 400 Bad Request.
الخطاطيف (Hooks)
الخطاطيف هي مجموعة خاصة من الدوال في Elysia تعمل كوسطاء (middlewares) تُطلَق بين الطلب والاستجابة. يمكنك إنشاء خطاطيف لكل مسار أو لعدة مسارات.
Before & After Handle
يمكن استخدام هذين الخطافين للتحقق من صحة الطلب قبل وصوله إلى مسار معين. على سبيل المثال، يمكنك التحقق مما إذا كان محتوى الطلب يحتوي على ترويسة مطلوبة:
app.get('/', () => 'happy path data', {
beforeHandle: (handler: Elysia.Handler) => {
if(!validateHeaders(headers)) {
// not happy path
set.status = 401
return 'Unauthorized'
}
}
}) OnResponse & OnError
تُستخدم هذه الخطاطيف عندما تريد اعتراض الاستجابة المرسَلة من المسار أو التعامل مع الخطأ.
على سبيل المثال، يمكنك استخدام خطاف OnResponse لتسجيل جميع الاستجابات:
app
.onResponse((handler: Elysia.Handler) => {
console.log(`Global Handler - Method: ${handler.request.method} | URL: ${handler.request.url} | Status Code: ${handler.set.status ||= 500}`)
})عند تطبيق الخطاطيف على عدة مسارات دفعة واحدة، لا تنسَ أن الخطاف يجب أن يُستدعى قبل نقطة النهاية التي تريد تطبيقه عليها:
app
.use(hooksSetup)
.get('/', () => 'Hello Bun.js!')
يمكنك توسيع قدرات تطبيقك باستخدام الإضافات (plugins).
Swagger
لتوثيق واجهاتك البرمجية، يمكنك إعداد Swagger باستخدام إضافة:
> bun add @elysiajs/swaggerثم في المشروع، ببساطة استورده واحقنه في وسيط:
import { swagger } from '@elysiajs/swagger';
app
.use(
swagger({
path: '/v1/swagger', // endpoint which swagger will appear on
documentation: {
info: {
title: 'Bun.js CRUD app with Elysia.js',
version: '1.0.0',
},
},
})
); ملاحظة مهمة
لتغطية مساراتك بـ Swagger، تحتاج لوضع وسيط Swagger فوق المسارات التي تريد توثيقها:
app
.use(docsSetup) // <-- Swagger
// routes below will appear in the Swagger
.get('/', () => 'Hello Bun.js!')
.group('/api', (app: Elysia) =>
المُسجِّل (Logger)
يحتوي Elysia على وسيط تسجيل بسيط يمكنه فقط تسجيل الطلبات في وحدة التحكم.
> bun add @grotto/logysiaimport { logger } from '@grotto/logysia';
app
.use(logger())
// will fire on any request below this line CORS
لتفعيل CORS في تطبيق Bun/Elysia لديك، تأكد من تثبيت إضافة CORS:
> bun add @elysiajs/corsثم طبّقها على المشروع:
import { cors } from '@elysiajs/cors';
app
.use(cors(/* your options */)) تأمين الترويسات باستخدام Helmet.js
يمكنك استخدام مكتبة Helmet الخاصة بـ Node.js للأمان مع Bun أيضًا. ببساطة أضفها إلى المشروع:
> bun add elysia-helmetثم استوردها في الموجّه كوسيط:
import { cors } from '@elysiajs/cors';
import { helmet } from 'elysia-helmet';
app
.use(cors(/* your options */))
.use(
helmet({ /* your options */ })
) المصادقة باستخدام JWT
من الطرق الشائعة لتأمين التطبيقات استخدام JSON Web Tokens. لتفعيل JWT في Bun، ببساطة ثبّته:
> bun add @elysiajs/jwtثم أضفه إلى المشروع كوسيط للموجّه:
import { jwt } from '@elysiajs/jwt';
app
.use(
jwt({
name: 'jwt',
secret: process.env.JWT_SECRET as string,
})
)هذا يوسّع بشكل أساسي كائن المعالِج لديك بخاصية JWT. هكذا تصل إليها لاحقًا:
app.post('/', async (handler: Elysia.Handler) => {
const accessToken = await handler.jwt.sign({
/* Your Payload */
});
});
إعداد MongoDB
أفضل طريقة للعمل مع MongoDB هي استخدام Mongoose، وهو ODM الخاص بـ JavaScript لـ Mongo. أولًا، ثبّت Mongoose في المشروع:
> bun add mongooseبعد التثبيت، أنشئ مجلدًا داخل مجلد src حيث ستُعدّ اتصال MongoDB.
├── src
├── index.ts
├── database
├── db.setup.tsعادةً ما أنشئ قاعدة بيانات مجانية على MongoDB Cloud. لكن يمكنك أيضًا تشغيل نسخة محلية.
الاتصال بـ MongoDB
بعد الانتهاء من الإعداد، ضع سلسلة الاتصال في ملف .env:
MONGODB_URI=mongodb+srv://<YOUR-CONNECTION-STRING>ثم استخدم Mongoose للاتصال باستخدام سلسلة الاتصال:
import mongoose from 'mongoose';
// Connect to the MongoDB database
const mongoDBURI = process.env.MONGODB_URI ?? 'mongodb://localhost:27017';
mongoose.connect(mongoDBURI);
export default mongoose;وأخيرًا، استورد ملف إعداد قاعدة البيانات هذا إلى ملف index.ts:
import { Elysia } from 'elysia';
import './database/db.setup';
// ... إعداد الكيان (Entity)
للعمل مع بيانات قاعدة البيانات، تحتاج لإعداد نموذج كيان. بما أن MongoDB قاعدة بيانات مستندية، تُنشأ النماذج كمخططات JSON.
أولًا، أعدّ واجهة سيُبنى عليها المخطط.
import { Document, Schema, model } from 'mongoose';
export interface IUser extends Document {
username: string;
email: string;
password: string;
}ثانيًا، أنشئ مخطط User. أضفت بعض القيود لفرض اسم مستخدم وبريد إلكتروني فريدَين، وجعل كل حقل إلزاميًا، وحذف كلمة المرور من الاستجابة.
const schema = new Schema<IUser>(
{
username: {
type: String,
required: true,
unique: true,
},
email: {
type: String,
required: true,
unique: true,
},
password: {
type: String,
required: true,
select: false, // will not appear in the response
},
},
{
timestamps: true,
}
);
export default model<IUser>('user', schema) متحكم المستخدمين
الآن يأتي جزء CRUD. تُعدّ متحكمًا سيتفاعل مع قاعدة البيانات باستخدام مخطط User الذي أنشأناه سابقًا.
مسار متحكم المستخدمين
├── src
├── index.ts
├── controllers
├── users.controller.tsواجهة CRUD البرمجية
import { Elysia, t } from 'elysia';
import User, { IUser } from '../entities/user.schema';
import { jwt } from '@elysiajs/jwt';
export const usersController = (app: Elysia) =>
app.group('/users', (app: Elysia) =>
app
// Using JWT
.use(
jwt({
name: 'jwt',
secret: process.env.JWT_SECRET as string,
})
)
// Validating required properties using Guard schema
.guard({
body: t.Object({
username: t.String(),
email: t.String(),
password: t.String()
})
}, (app: Elysia) => app
// This route is protected by the Guard above
.post('/', async (handler: Elysia.Handler) => {
try {
const newUser = new User();
newUser.username = handler.body.username;
newUser.email = handler.body.email;
newUser.password = handler.body.password;
const savedUser = await newUser.save();
const accessToken = await handler.jwt.sign({
userId: savedUser._id
});
handler.set.headers = {
'X-Authorization': accessToken,
};
handler.set.status = 201;
return newUser;
} catch (e: any) {
if (e.name === 'MongoServerError' && e.code === 11000) {
handler.set.status = 422;
return { message: 'Resource already exists!', status: 422 };
}
handler.set.status = 500;
return { message: 'Unable to save entry to the database!', status: 500 };
}
})
)
.get('/', async ({ set }: Elysia.Set) => {
try {
const users = await User.find({});
return users;
} catch (e: unknown) {
set.status = 500;
return { message: 'Unable to retrieve items from the database!', status: 500 };
}
})
.get('/:id', async (handler: Elysia.Handler) => {
try {
const { id } = handler.params;
const existingUser = await User.findById(id);
if (!existingUser) {
handler.set.status = 404;
return { message: 'Requested resource was not found!', status: 404 };
}
return existingUser;
} catch (e: unknown) {
handler.set.status = 500;
return { message: 'Unable to retrieve the resource!', status: 500 };
}
})
.patch('/:id', async (handler: Elysia.Handler) => {
try {
const { id } = handler.params;
const changes: Partial<IUser> = handler.body;
const updatedUser = await User.findOneAndUpdate(
{ _id: id },
{ $set: { ...changes } },
{ new: true }
);
if (!updatedUser) {
handler.set.status = 404;
return { message: `User with id: ${id} was not found.`, status: 404 };
}
return updatedUser;
} catch (e: unknown) {
handler.set.status = 500;
return { message: 'Unable to update resource!', status: 500 };
}
})
.delete('/:id', async (handler: Elysia.Handler) => {
try {
const { id } = handler.params;
const existingUser = await User.findById(id);
if (!existingUser) {
handler.set.status = 404;
return { message: `User with id: ${id} was not found.`, status: 404 };
}
await User.findOneAndRemove({ _id: id });
return { message: `Resource deleted successfully!`, status: 200 };
} catch (e: unknown) {
handler.set.status = 500;
return { message: 'Unable to delete resource!', status: 500 };
}
})
); تسجيل متحكم المستخدمين
لتجميع كل شيء معًا، استورد المتحكم في ملف index.ts ومرّر مرجع المتحكم إلى الوسيط.
import { usersController } from './controllers/users.controller';
app
.group('/api', (app: Elysia) =>
app.use(usersController) // <-- Wiring up the dependencies
)متحكم المستخدمين جاهز للاستخدام الآن.
الاختبار
عامل مهم آخر في تطوير البرمجيات هو الاختبار. عند الاختبار، لا يجب استخدام بيانات الإنتاج. بدلًا من ذلك، تعتمد الاختبارات على بيانات مزيفة (fakes) ووهمية (mocks) وبديلة (stubs). تُملأ قاعدة البيانات ببيانات يمكنك التجربة عليها دون الإضرار بالبيانات الحقيقية.
يوفّر Bun أداة تشغيل اختبارات مدمجة يمكنك استخدامها لاختبار دوالك وواجهاتك البرمجية.
قاعدة بيانات الاختبار
حدّثت ملف إعداد قاعدة البيانات لاستخدام قاعدة بيانات الاختبار فقط في بيئة الاختبار (عندما تكون NODE_ENV === 'test').
let mongoDBURI;
if (process.env.NODE_ENV === 'test') {
mongoDBURI = process.env.TEST_MONGODB_URI ?? 'mongodb://localhost:27017';
} else {
mongoDBURI = process.env.MONGODB_URI ?? 'mongodb://localhost:27017';
}
mongoose.connect(mongoDBURI); سكريبت مُشغّل الاختبارات
حدّث ملف package.json لإضافة خطاف اختبار ضمن السكريبتات. سيُشغّل خطاف الاختبار bun test على أي ملف ينتهي بـ .test.ts.
"scripts": {
"start": "bun src/index.ts",
"dev": "bun run --watch src/index.ts",
"test": "bun test ./test/**.test.ts"
}, مجلد الاختبارات
أنشأت مجلدًا منفصلًا باسم "test" في جذر المشروع سيحتوي على جميع ملفات الاختبار.
├── src
├── test
├── users.test.ts
└── package.json الاختبارات الفعلية
يمكنك اختبار كل نقطة نهاية باستخدام صنف Request وواجهة Fetch البرمجية. أولًا، تحتاج لتصدير كائن app (نسخة Elysia) لاستخدامه كموجّه ضمن الاختبارات.
export const app = new Elysia();بعدها ضمن الاختبارات، استفد من app وFetch API لمحاكاة طلبات API:
const baseUrl = `${app.server?.hostname}:${app.server?.port}/api/users`;
describe('GET Users suite', () => {
it('should return a list of users successfully', async () => {
const req = new Request(baseUrl);
const res = await app.fetch(req);
expect(res.status).toEqual(200);
});
it('should not return a user password', async () => {
const userId = '64e87ae42400ef4b2cd1ae95';
const req = new Request(`${baseUrl}/${userId}`);
const res = await app.fetch(req);
expect(res.status).toEqual(200);
const responseBody = await res.json();
expect(responseBody.password).toEqual(undefined);
});
})
describe('CREATE Users suite', () => {
it('should fail to create a user that already exists', async () => {
const existingUser = {
username: 'Jack31',
email: '[email protected]',
password: 'test123'
}
const expected = { message: 'Resource already exists!' };
const req = new Request(baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(existingUser)
});
const res = await app.fetch(req);
expect(res.status).toEqual(422);
const responseBody = await res.json();
expect(responseBody.message).toEqual(expected.message);
});
}) تشغيل مجموعة الاختبارات
لتشغيل مُشغّل الاختبارات، نفّذ سكريبت الاختبار في ملف package.json:
> bun test
bun test v1.0.0 (822a00c4)
[0.09ms] ".env"
test/users.test.ts:
🦊 Elysia is running at localhost:3000
✓ USERS Test suite > GET Users suite > should return a list of users successfully [1015.90ms]
✓ USERS Test suite > GET Users suite > should return a user successfully using existing id [967.09ms]
✓ USERS Test suite > GET Users suite > should not return a user password [47.86ms]
✓ USERS Test suite > GET Users suite > should fail to return a user that does not exist [1.88ms]
✓ USERS Test suite > CREATE Users suite > should create a new user successfully [64.82ms]
✓ USERS Test suite > CREATE Users suite > should fail to create a user that already exists [53.65ms]
✓ USERS Test suite > CREATE Users suite > should fail to create a user when mandatory fields are not provided [6.28ms]
✓ USERS Test suite > PATCH Users suite > should update a user successfully [71.80ms]
✓ USERS Test suite > PATCH Users suite > should fail to update a user that does not exist [0.58ms]
✓ USERS Test suite > DELETE Users suite > should delete a user successfully [100.40ms]
✓ USERS Test suite > DELETE Users suite > should fail to delete a user that does not exist [0.80ms]يمكنك إيجاد مجموعة الاختبارات كاملة في مستودع GitHub الخاص بالمشروع.
آراء حول Bun
Bun سريع وممتع للعمل معه، لكنه لا يزال يحتوي على بعض العلل. على سبيل المثال:
- يشتكي المحرر من أخطاء رغم أن المترجم يعمل بشكل جيد
- يرمي Mongoose خطأً عند محاولة استرجاع عنصر غير موجود
- لا يمكنك استخدام الكثير من الحزم من أطر عمل أخرى (مثل Express أو Fastify أو Nest.js) لأنها تعمل على نموذج Request/Response، وفي Elysia لا يوجد سوى كائن معالِج سياق واحد لكل نقطة نهاية
لكن لا شيء منها معطِّل حقًا أو لا يمكن إصلاحه بتصحيح بسيط. سأبقي بالتأكيد عيني على Bun وأشجعك على تجربته.
الحصول على الكود الكامل
لا تتردد في استنساخ المشروع واللعب به: https://github.com/Leka-Workshop/Bun-CRUD-App
لمعرفة المزيد عن Bun
- توثيق Bun: https://bun.sh/docs/installation
- توثيق Elysia: https://elysiajs.com/introduction