إذا سبق لك أن استدعيت نقطة نهاية REST لمجرد الحصول على مُعرّف، ثم استدعيت نقطة أخرى لاستخدام ذلك المُعرّف، فأنت تعرف ألم «شلال الشبكة». كل ذهاب وإياب يضيف زمن استجابة، وعبر اتصال هاتفي بطيء في منطقة الشرق الأوسط وشمال إفريقيا تتراكم هذه الرحلات بسرعة. Cap'n Web هو نظام RPC أصيل في JavaScript من Cloudflare، صدر أواخر عام 2025، ويطوي تلك السلاسل في رحلة واحدة فقط — بدون مُترجم مخطط (schema)، وبدون توليد كود، وبحجم أقل من 10 كيلوبايت وصفر اعتماديات.
في هذا الدرس ستبني واجهة RPC صغيرة لكن مكتملة وتستهلكها من عميل TypeScript. وفي الطريق ستتعلم الأجزاء التي تجعل Cap'n Web مختلفًا فعلًا عن tRPC أو fetch العادي: توجيه الوعود (promise pipelining)، والكائنات المُمرّرة بالمرجع، ودوال الاستدعاء التي تعمل على الخادم، والدالة المفاجئة .map().
المتطلبات الأساسية
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبّت (يعتمد Cap'n Web على ميزات ES الحديثة)
- إلمام بـ TypeScript و async/await
- فهم أساسي لـ HTTP وWebSockets
- محرر أكواد (يُنصح بـ VS Code)
لست بحاجة إلى حساب Cloudflare. يعمل Cap'n Web في Node.js وDeno وBun والمتصفحات وعلى Cloudflare Workers — سنستخدم Node.js للخادم حتى يعمل كل شيء محليًا.
ما الذي ستبنيه
واجهة مستخدم مصادَق عليها بسيطة بثلاث قدرات:
- دالة عامة للمصادقة باستخدام رمز (token)، تُعيد كائن جلسة مصادَقًا عليه.
- دوال على تلك الجلسة لقراءة مُعرّف المستخدم وقائمة أصدقائه.
- بحث عن ملف شخصي نُسلسله مع الجلسة في رحلة شبكة واحدة عبر توجيه الوعود.
في النهاية سيكون لديك خادم (server.ts)، وعقد أنواع مشترك (api.ts)، وعميل (client.ts) يوضح التجميع (batching) والتوجيه (pipelining) و.map().
ما الذي يميّز Cap'n Web
معظم أدوات RPC (مثل tRPC وoRPC وgRPC-web) ترسل طلبًا واحدًا لكل استدعاء، أو تتطلب منك التجميع يدويًا. أما Cap'n Web فيُنمذج الكائنات البعيدة على هيئة stubs: عندما تستدعي دالة، تحصل فورًا على وعد يمثّل قيمة مستقبلية موجودة على الخادم. يمكنك تمرير ذلك الوعد إلى الاستدعاء التالي قبل أن يُحَلّ. يسجّل Cap'n Web رسم الاعتمادية بالكامل ويُرسله إلى الخادم كرسالة واحدة — يشغّل الخادم السلسلة محليًا ويُعيد النتائج النهائية فقط.
هذا هو توجيه الوعود (promise pipelining)، وهي فكرة مستعارة من Cap'n Proto. إنها الميزة الأبرز، وسنبني نحوها خطوة بخطوة.
الخطوة 1: إعداد المشروع
أنشئ مشروعًا جديدًا وثبّت الاعتمادية الوحيدة:
mkdir capnweb-demo && cd capnweb-demo
npm init -y
npm install capnweb
npm install -D typescript tsx @types/node ws @types/wsنستخدم ws لخادم WebSocket وtsx لتشغيل ملفات TypeScript مباشرة. أنشئ ملف tsconfig.json بإعدادات حديثة — تتطلب تصريحات using المستوى ES2022 أو أحدث لرموز التخلّص (disposal):
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}اضبط نوع الحزمة على ESM حتى تعمل عمليات الاستيراد كما هي مكتوبة:
{
"type": "module"
}الخطوة 2: تعريف عقد الواجهة المشترك
لا يملك Cap'n Web لغة مخطط — واجهات TypeScript لديك هي العقد. أنشئ api.ts ووصِّف شكل الكائنات البعيدة. يستورد كل من الخادم والعميل هذه الأنواع، وهذا ما يمنحك أمان النوع من الطرف إلى الطرف بدون أي توليد كود.
// api.ts
export interface UserProfile {
id: number;
name: string;
bio: string;
}
// الجلسة المصادَق عليها، تُعاد بعد تسجيل الدخول.
// تعمل الدوال هنا عن بُعد على الخادم.
export interface AuthedApi {
getUserId(): number;
getFriendIds(): number[];
}
// نقطة الدخول العامة المعروضة على نقطة نهاية RPC.
export interface PublicApi {
authenticate(token: string): AuthedApi;
getUserProfile(userId: number): UserProfile;
}لاحظ أن authenticate تُعيد AuthedApi — كائنًا آخر وليس بيانات بسيطة. هذا هو مفتاح التوجيه: الجلسة المُعادة هي مرجع بعيد يمكنك مواصلة الاستدعاء عليه.
الخطوة 3: تنفيذ الخادم
كائن الخادم يرث من RpcTarget. تُعرض فقط دوال النموذج الأولي (prototype) والـ getters؛ أما حقول النسخة (instance) فتبقى خاصة. أنشئ server.ts:
// server.ts
import http from "node:http";
import { WebSocketServer } from "ws";
import {
RpcTarget,
newWebSocketRpcSession,
nodeHttpBatchRpcResponse,
} from "capnweb";
import type { PublicApi, AuthedApi, UserProfile } from "./api.ts";
// مخزن بيانات وهمي
const USERS: Record<number, UserProfile> = {
1: { id: 1, name: "أميرة", bio: "مهندسة خلفية في تونس" },
2: { id: 2, name: "يوسف", bio: "مصمم في الرياض" },
3: { id: 3, name: "لينا", bio: "مديرة منتج في الدار البيضاء" },
};
// الجلسة المصادَق عليها — مُمرّرة بالمرجع لأنها ترث RpcTarget.
class AuthedSession extends RpcTarget implements AuthedApi {
// يُخزَّن userId على النسخة، فيبقى خاصًا بالخادم.
constructor(private readonly userId: number) {
super();
}
getUserId(): number {
return this.userId;
}
getFriendIds(): number[] {
// لنفترض أن الجميع أصدقاء مع المستخدمَين التاليين.
return [this.userId + 1, this.userId + 2].filter((id) => USERS[id]);
}
}
// نقطة الدخول العامة.
class PublicServer extends RpcTarget implements PublicApi {
authenticate(token: string): AuthedApi {
// في الكود الحقيقي، تحقق من JWT أو رمز جلسة هنا.
if (!token.startsWith("user-")) {
throw new Error("رمز غير صالح");
}
const userId = Number(token.slice("user-".length));
if (!USERS[userId]) {
throw new Error("مستخدم غير معروف");
}
// إعادة RpcTarget يمنح العميل مرجعًا بعيدًا حيًا.
return new AuthedSession(userId);
}
getUserProfile(userId: number): UserProfile {
const profile = USERS[userId];
if (!profile) {
throw new Error(`لا يوجد ملف للمستخدم ${userId}`);
}
return profile;
}
}
// تقديم HTTP batch على /api وWebSocket على المنفذ نفسه.
const httpServer = http.createServer(async (req, res) => {
if (req.url === "/api") {
try {
await nodeHttpBatchRpcResponse(req, res, new PublicServer(), {
headers: { "Access-Control-Allow-Origin": "*" },
});
} catch (err) {
res.writeHead(500, { "content-type": "text/plain" });
res.end(String((err as Error)?.stack ?? err));
}
return;
}
res.writeHead(404);
res.end("Not Found");
});
// إرفاق خادم WebSocket للجلسات طويلة الأمد.
const wsServer = new WebSocketServer({ server: httpServer });
wsServer.on("connection", (ws) => {
// PublicServer جديد لكل اتصال.
newWebSocketRpcSession(ws as unknown as WebSocket, new PublicServer());
});
httpServer.listen(8080, () => {
console.log("خادم Cap'n Web على http://localhost:8080/api");
});ناقلان (transports)، صنف خادم واحد:
- يتعامل
nodeHttpBatchRpcResponseمع طلبات HTTP batch — مثالي للدفعات أحادية اللقطة عديمة الحالة. - يتعامل
newWebSocketRpcSessionمع WebSocket طويل الأمد — مثالي عندما يجري العميل استدعاءات على مدى الوقت أو عندما يحتاج الخادم إلى الاستدعاء العكسي.
شغّله:
npx tsx server.tsنصيحة: يحصل كل اتصال WebSocket على
new PublicServer()خاص به. احتفظ بالحالة الخاصة بكل اتصال (مثل الجلسة المصادَق عليها) على النسخ (instances)، وليس على متغيرات عامة على مستوى الوحدة، حتى لا يرى عميلان بيانات بعضهما.
الخطوة 4: عميل أساسي وتجميع HTTP
الآن العميل. أنشئ client.ts وابدأ بأبسط حالة — استدعاءان مستقلان مُجمَّعان في طلب HTTP واحد:
// client.ts
import { newHttpBatchRpcSession } from "capnweb";
import type { PublicApi } from "./api.ts";
async function basicBatch() {
// 'using' يتخلص تلقائيًا من الجلسة عند الخروج من الكتلة.
using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
// ابدأ كلا الاستدعاءين دون await — يُصطفّان في الطابور.
const aliceProfile = api.getUserProfile(1);
const youssefProfile = api.getUserProfile(2);
// الانتظار يُفرغ الدفعة: كلا الاستدعاءين يسافران في طلب HTTP واحد.
const [a, y] = await Promise.all([aliceProfile, youssefProfile]);
console.log(a.name, "/", y.name); // أميرة / يوسف
}
basicBatch();الكلمة المفتاحية using هي الإدارة الصريحة للموارد في JavaScript. عندما يخرج api من النطاق، يتم التخلص من اتصاله تلقائيًا — بدون حاجة إلى try/finally. ومع ناقل HTTP batch، لا يُرسَل الطلب إلا عند await، لذا فإن كل ما تصطفّه قبل ذلك يسافر معًا.
شغّله في طرفية أخرى:
npx tsx client.tsالخطوة 5: توجيه الوعود — الحدث الرئيسي
هنا الجزء الذي لا يستطيع REST فعله. نريد أن:
- نصادِق برمز.
- نحصل على مُعرّف المستخدم المصادَق عليه.
- نجلب ملف ذلك المستخدم الكامل.
مع fetch، هذه ثلاث رحلات متسلسلة لأن كل خطوة تعتمد على نتيجة سابقتها. مع Cap'n Web، إنها واحدة:
// client.ts (تابع)
import { newHttpBatchRpcSession } from "capnweb";
import type { PublicApi } from "./api.ts";
async function pipeline() {
using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
// 1. authenticate() تُعيد stub لـ AuthedApi — دون await.
const session = api.authenticate("user-1");
// 2. استدعِ ذلك الـ stub. userIdPromise قيمة مستقبلية على الخادم.
const userIdPromise = session.getUserId();
// 3. مرّر الوعد غير المُحَلّ مباشرة إلى الاستدعاء التالي.
// يسجّل Cap'n Web الاعتمادية بدلًا من حلّها محليًا.
const profilePromise = api.getUserProfile(userIdPromise);
// await واحد -> رحلة واحدة لكل العمليات الثلاث.
const profile = await profilePromise;
console.log(profile); // { id: 1, name: 'أميرة', bio: '...' }
}
pipeline();اقرأ ذلك مجددًا: api.getUserProfile(userIdPromise) تُمرَّر وعدًا، لا رقمًا. يرى Cap'n Web أن userIdPromise نتيجة استدعاء سابق في الدفعة نفسها، فيعيد كتابة الطلب بحيث يحلّ الخادم getUserId() أولًا، ثم يُغذّيه إلى getUserProfile() — كل ذلك قبل إرسال أي شيء عكسيًا. لا يرى العميل مُعرّف المستخدم الوسيط أبدًا ما لم يطلبه.
هذا هو الفرق بين ثلاثة طلبات HTTP وواحد. على اتصال هاتفي بزمن 200 مللي ثانية، يكون ذلك 600 مللي ثانية مقابل 200 مللي ثانية — مكسب ملموس للمستخدمين على الشبكات الأبطأ.
الخطوة 6: الدالة السحرية .map()
ماذا لو صادقت، وحصلت على قائمة بمُعرّفات الأصدقاء، وأردت ملف كل صديق؟ بسذاجة هذا استدعاء واحد للقائمة، ثم N استدعاءً للملفات. تشغّل دالة Cap'n Web .map() التحويل على الخادم، فيظل كل شيء في رحلة واحدة:
// client.ts (تابع)
async function fanOut() {
using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
const session = api.authenticate("user-1");
// getFriendIds() تُعيد RpcPromise<number[]>.
// .map() تجدول تحويلًا على الخادم لكل عنصر.
const friendProfiles = session
.getFriendIds()
.map((friendId) => api.getUserProfile(friendId));
// لا تزال رحلة واحدة: مصادقة -> مُعرّفات الأصدقاء -> N بحث ملف.
const profiles = await friendProfiles;
console.log(profiles.map((p) => p.name)); // ['يوسف', 'لينا']
}
fanOut();دالة الاستدعاء المُمرَّرة إلى .map() خاصة: يجب أن تكون متزامنة، وآثارها الجانبية الوحيدة ذات المعنى هي استدعاءات RPC إضافية، وfriendId التي تستقبلها هي نفسها RpcPromise بعيد — لا يمكنك مثلًا إجراء حساب friendId + 1 عليها مباشرة. فكّر في .map() على أنها «صِف حلقة على الخادم»، لا «شغّل حلقة JavaScript». ضمن هذه القواعد، تقضي تمامًا على شلال استعلامات N+1 الكلاسيكي.
الخطوة 7: تمرير دوال الاستدعاء إلى الخادم
لأن الدوال من الدرجة الأولى في Cap'n Web، يمكنك تمرير دالة من العميل كوسيط ويمكن للخادم استدعاؤها عن بُعد. هذا هو أساس الاشتراكات الحية ودوال استدعاء التقدّم. أضف دالة إلى الخادم:
// server.ts — أضف إلى PublicServer
notifyEach(
ids: number[],
onItem: (name: string) => void,
): number {
for (const id of ids) {
const profile = USERS[id];
if (profile) onItem(profile.name); // استدعاء عكسي للعميل
}
return ids.length;
}ثم على العميل، مرّر دالة عادية. استخدم جلسة WebSocket هنا، لأن دوال الاستدعاء تحتاج إلى اتصال ثنائي الاتجاه طويل الأمد:
// client.ts (تابع)
import { newWebSocketRpcSession } from "capnweb";
import type { PublicApi } from "./api.ts";
async function withCallback() {
using api = newWebSocketRpcSession<PublicApi & {
notifyEach(ids: number[], onItem: (name: string) => void): number;
}>("ws://localhost:8080");
const count = await api.notifyEach([1, 2, 3], (name) => {
// هذا يعمل على العميل، ويستدعيه الخادم عن بُعد.
console.log("أبلغ الخادم عن:", name);
});
console.log("إجمالي المُبلَّغ عنهم:", count);
}
withCallback();تُمرَّر الدالة بالمرجع على هيئة stub. يحتفظ الخادم بمقبض إليها ويستدعيها عبر الشبكة. تتيح هذه الآلية نفسها للخادم دفع تحديثات إلى عميل متصل — بديل نظيف عن إنشاء قناة أحداث منفصلة.
الخطوة 8: معالجة الأخطاء والتخلص
الأخطاء المرمية على الخادم تُسلسَل وتُرمى من جديد على العميل، لذا يعمل try/catch العادي:
async function handleErrors() {
using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
try {
await api.authenticate("bad-token"); // يرمي الخادم "رمز غير صالح"
} catch (err) {
console.error("فشلت المصادقة:", (err as Error).message);
}
}للجلسات طويلة الأمد عبر WebSocket، استمع لانقطاع الاتصال حتى تتمكن من إعادة الاتصال أو إظهار حالة عدم الاتصال:
const api = newWebSocketRpcSession<PublicApi>("ws://localhost:8080");
api.onRpcBroken((error) => {
console.error("فُقد الاتصال:", error);
// كل الاستدعاءات اللاحقة على هذا الـ stub سترفض.
});عندما تنشئ stub دون using، تخلّص منه يدويًا حتى يتمكن الخادم من تحرير أي كائنات مرتبطة به:
const api = newWebSocketRpcSession<PublicApi>("ws://localhost:8080");
await api.getUserProfile(1);
api[Symbol.dispose](); // يغلق الاتصالإذا احتجت أن يبقى كائن بعيد حيًا بعد الاستدعاء الذي أتى منه، فضاعِفه بـ .dup() قبل تمرير الأصل إلى مكان قد يتخلص منه.
الخطوة 9: النشر على Cloudflare Workers
بنى Cloudflare لـ Cap'n Web، لذا فإن Workers هي بيئته الأكثر طبيعية. يعمل صنف الخادم RpcTarget نفسه دون تغيير — تختلف نقطة الدخول فقط:
// worker.ts
import { RpcTarget, newWorkersRpcResponse } from "capnweb";
import type { PublicApi } from "./api.ts";
class PublicServer extends RpcTarget implements PublicApi {
// ...التنفيذ نفسه كما سبق...
}
export default {
fetch(request: Request) {
const url = new URL(request.url);
if (url.pathname === "/api") {
return newWorkersRpcResponse(request, new PublicServer());
}
return new Response("Not found", { status: 404 });
},
};انشر بـ wrangler deploy، ووجّه رابط عميلك إلى الـ Worker، فيكون لديك نقطة نهاية RPC موزعة عالميًا. كما أن Bun وDeno مدعومان أيضًا، عبر newBunWebSocketRpcHandler وnewHttpBatchRpcResponse على التوالي.
اختبار تنفيذك
شغّل الخادم في طرفية والعميل في أخرى. عليك التحقق من كل سلوك على حدة:
- التجميع: سجّل عدّادًا في
nodeHttpBatchRpcResponseأو راقب الشبكة — استدعاءاgetUserProfileيُنتجان طلبًا واحدًا. - التوجيه: يُعيد تدفق المصادقة الثلاثي (مصادقة → مُعرّف → ملف) ملفًا بـ
awaitواحد. أضفconsole.logفي كل دالة على الخادم لتأكيد أنها جميعًا تعمل ضمن طلب واحد. - .map(): يطبع
fanOut()القيمة['يوسف', 'لينا']دون حلقة على جانب العميل عبر الشبكة. - دوال الاستدعاء: يطبع
withCallback()«أبلغ الخادم عن:» ثلاث مرات، مما يثبت أن الخادم استدعى دالة عميلك.
إذا تعلّق استدعاء، تأكد من أن الخادم يستمع على المنفذ 8080 وأن رابط عميلك يستخدم http:// للدفعة وws:// لـ WebSocket.
استكشاف الأخطاء وإصلاحها
using خطأ نحوي. هدف TypeScript لديك أدنى من ES2022، أو بيئة التشغيل قديمة. ارفع target إلى ES2022 واستخدم Node.js 20+.
Cannot read properties of undefined عند استدعاء دالة. تُعرض دوال النموذج الأولي فقط. إذا عرّفت دالة كحقل نسخة من نوع arrow (method = () => {})، فانقلها إلى دالة صنف عادية حتى تعيش على النموذج الأولي.
العميل لا يحصل على رد أبدًا عبر HTTP batch. لا تُفرَّغ الدفعة إلا عند await. تأكد من وجود ما ينتظر الوعود المُصطفّة.
فشل تسلسل وسيط من نوع Map أو Set أو RegExp. هذه الأنواع ليست مُمرّرة بالقيمة في Cap'n Web. حوّلها إلى كائنات/مصفوفات عادية، أو غلّف السلوك في RpcTarget.
تسرّب الحالة بين المستخدمين. خزّنت بيانات الجلسة على متغير عام على مستوى الوحدة بدلًا من نسخة. أنشئ new PublicServer() لكل اتصال واحتفظ بالحالة الخاصة بكل مستخدم على نسخ RpcTarget.
Cap'n Web مقابل tRPC وoRPC
إذا سبق أن استخدمت tRPC أو oRPC، فإليك تحوّل النموذج الذهني. يمنحك tRPC إجراءات مُنمَّطة عبر HTTP، مُجمَّعة لكن ما زالت مسطحة — كل استدعاء مستقل. أما Cap'n Web فيمنحك كائنات مُنمَّطة تُعيد دوالها مزيدًا من الكائنات، ويوجّه الاستدعاءات المعتمدة في رحلة واحدة. يتكامل tRPC اليوم بإحكام مع React Query ومنظومة Next.js؛ بينما Cap'n Web أنحف، ومحايد تجاه الناقل، ويتألق عندما تكون سلاسل الاستدعاء عميقة أو عندما يهيمن زمن الاستجابة. وهما ليسا متعارضين — قد تبقي tRPC لطبقة بيانات Next.js وتلجأ إلى Cap'n Web في مسار حرج زمنيًا وكثيف برسوم الكائنات.
الخطوات التالية
- أضف مصادقة حقيقية بالتحقق من JWT داخل
authenticate()وتخزين المطالبات المُتحقَّق منها على نسخةAuthedSession. - استكشف الجلسات ثنائية الاتجاه عبر WebSocket حيث يحتفظ الخادم بـ stubs لدوال العميل ليدفع تحديثات حية.
- ادمج Cap'n Web مع Cloudflare Workers وDurable Objects لـ RPC ذي حالة وموزع عالميًا.
- اقرأ مواصفات البروتوكول لفهم كيفية ربط جداول التصدير/الاستيراد والتوجيه على مستوى الشبكة.
الخاتمة
يعيد Cap'n Web التفكير في نموذج الطلب/الاستجابة حول الكائنات والقيم المستقبلية بدلًا من نقاط النهاية المسطحة. بمعاملة النتائج البعيدة كوعود يمكنك تمريرها للأمام، يطوي سلاسل الاستدعاء الكاملة في رحلة واحدة — مزيلًا كلًا من شلال زمن الاستجابة ومشكلة استعلامات N+1. ومع أنواع TypeScript من الطرف إلى الطرف من واجهة بسيطة، وبدون توليد كود، وبصمة أقل من 10 كيلوبايت، فإنه طريقة نظيفة بشكل غير معتاد للتحدث بين المتصفح والخادم.
لقد بنيت واجهة مصادَقة كاملة، واستهلكتها بثلاث طرق — دفعة، وتوجيه، وتوزيع .map() — ومرّرت دالة استدعاء استدعاها الخادم عن بُعد، ورأيت كيف يُنشر صنف الخادم نفسه على Cloudflare Workers. في المرة القادمة التي تضبط فيها نفسك تسلسل استدعاءات fetch، تذكّر أن بإمكانك وصف السلسلة كاملة مرة واحدة وترك الخادم يشغّلها.
لمزيد عن الواجهات الآمنة النوع والنشر على الحافة، استكشف أدلتنا حول tRPC مع App Router وCloudflare Workers مع Hono وD1.