كيف يقضي tRPC على مشكلة عقد API، ويعمل مع Next.js App Router، ويتعامل مع وسيط المصادقة، ورفع الملفات، والاشتراكات. إعداد tRPC واقعي من الصفر.
تعرف السيناريو. تغيّر اسم حقل في استجابة API. تحدّث نوع الخلفية. تنشر. ثم الواجهة الأمامية تتعطل في الإنتاج لأن شخصاً نسي تحديث استدعاء fetch في السطر 247 من Dashboard.tsx. الحقل الآن undefined، والمكوّن يعرض فارغاً، ونظام تتبع الأخطاء يشتعل في الساعة 2 صباحاً.
هذه هي مشكلة عقد API. ليست مشكلة تقنية. إنها مشكلة تنسيق. ولن يصلح أي قدر من وثائق Swagger أو مخططات GraphQL حقيقة أن أنواع الواجهة الأمامية والخلفية يمكن أن تنحرف عن بعضها بصمت.
tRPC يصلح هذا برفض السماح لها بالانحراف. لا يوجد ملف مخطط. لا خطوة توليد كود. لا عقد منفصل للصيانة. تكتب دالة TypeScript على الخادم، والعميل يعرف أنواع المدخلات والمخرجات بالضبط في وقت التجميع. إذا أعدت تسمية حقل، الواجهة الأمامية لن تُجمّع حتى تصلحه.
هذا هو الوعد. دعني أُريك كيف يعمل فعلاً، وأين يتألق، وأين يجب ألا تستخدمه بالتأكيد.
لنلقِ نظرة على كيف تبني معظم الفرق واجهات API اليوم.
REST + OpenAPI: تكتب نقاط النهاية. ربما تضيف تعليقات Swagger. ربما تولّد SDK للعميل من مواصفة OpenAPI. لكن المواصفة هي عنصر منفصل. يمكن أن تصبح قديمة. خطوة التوليد هي شيء آخر في خط CI/CD يمكن أن ينكسر أو يُنسى. والأنواع المولّدة غالباً قبيحة — وحوش متداخلة بعمق مثل paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"].
GraphQL: أمان نوعي أفضل، لكن تكلفة إجرائية هائلة. تكتب مخططاً بـ SDL. تكتب المحلّلات. تولّد الأنواع من المخطط. تكتب الاستعلامات على العميل. تولّد الأنواع من الاستعلامات. هذا خطوتا توليد كود على الأقل، وملف مخطط، وخطوة بناء يجب أن يتذكرها الجميع. لفريق يتحكم بالفعل في الواجهة الأمامية والخلفية، هذه بنية تحتية كبيرة لمشكلة لها حل أبسط.
استدعاءات fetch يدوية: النهج الأكثر شيوعاً والأكثر خطورة. تكتب fetch("/api/users")، وتحوّل النتيجة إلى User[]، وتأمل الأفضل. لا يوجد أمان في وقت التجميع. تأكيد النوع هو كذبة تخبرها لـ TypeScript.
// الكذبة التي أخبرها كل مطور واجهة أمامية
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // نأمل أن هذا صحيحtRPC يتخذ نهجاً مختلفاً تماماً. بدلاً من وصف API بصيغة منفصلة وتوليد الأنواع، تكتب دوال TypeScript عادية على الخادم وتستورد أنواعها مباشرة في العميل. لا خطوة توليد. لا ملف مخطط. لا انحراف.
قبل أن نُعدّ أي شيء، لنفهم النموذج الذهني.
موجّه tRPC هو مجموعة من الإجراءات مُجمّعة معاً. فكّر فيه كمتحكم في MVC، إلا أنه مجرد كائن عادي مع استنتاج النوع مدمجاً.
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const appRouter = t.router({
user: t.router({
list: t.procedure.query(/* ... */),
byId: t.procedure.input(/* ... */).query(/* ... */),
create: t.procedure.input(/* ... */).mutation(/* ... */),
}),
post: t.router({
list: t.procedure.query(/* ... */),
publish: t.procedure.input(/* ... */).mutation(/* ... */),
}),
});
export type AppRouter = typeof appRouter;تصدير النوع AppRouter هذا هو كل السحر. العميل يستورد هذا النوع — ليس الكود التشغيلي، فقط النوع — ويحصل على إكمال تلقائي كامل وفحص نوع لكل إجراء.
الإجراء هو نقطة نهاية واحدة. هناك ثلاثة أنواع:
السياق هو البيانات المحددة بالطلب المتاحة لكل إجراء. اتصالات قاعدة البيانات، والمستخدم المصادق عليه، ورؤوس الطلب — أي شيء كنت ستضعه في كائن req في Express يذهب هنا.
الوسيط يحوّل السياق أو يحرس الوصول. النمط الأكثر شيوعاً هو وسيط مصادقة يتحقق من جلسة صالحة ويضيف ctx.user.
هذا هو النموذج الذهني الحاسم. عندما تعرّف إجراءً هكذا:
t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
// input مكتوب كـ { id: string }
return db.user.findUnique({ where: { id: input.id } });
});نوع الإرجاع يتدفق طوال الطريق إلى العميل. إذا أعاد db.user.findUnique النوع User | null، فإن خطاف useQuery للعميل سيكون data مكتوباً كـ User | null. لا كتابة يدوية. لا تحويل. يُستنتج من طرف إلى طرف.
لنبنِ هذا من الصفر. سأفترض أن لديك مشروع Next.js 14+ مع App Router.
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodأنشئ نسخة tRPC وعرّف نوع السياق.
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import superjson from "superjson";
import { ZodError } from "zod";
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
const session = await getSession(opts.req);
return {
session,
db: prisma,
req: opts.req,
};
}
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createCallerFactory = t.createCallerFactory;
export const router = t.router;
export const publicProcedure = t.procedure;بعض الأشياء يجب ملاحظتها:
superjson: tRPC يسلسل البيانات كـ JSON افتراضياً، مما يعني أن كائنات Date، وMap، وSet، والأنواع غير JSON الأخرى تُفقد. superjson يحافظ عليها.createTRPCContext: هذه الدالة تعمل على كل طلب. هنا تحلل الجلسة، وتُعدّ اتصال قاعدة البيانات، وتبني كائن السياق.// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
export const userRouter = router({
me: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: ctx.user.id },
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
},
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const updated = await ctx.db.user.update({
where: { id: ctx.user.id },
data: {
name: input.name,
bio: input.bio,
},
});
return updated;
}),
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
select: {
id: true,
name: true,
image: true,
bio: true,
},
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
});// src/server/routers/_app.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
import { notificationRouter } from "./notification";
export const appRouter = router({
user: userRouter,
post: postRouter,
notification: notificationRouter,
});
export type AppRouter = typeof appRouter;في App Router، يعمل tRPC كمعالج مسار قياسي. لا خادم مخصص، لا إضافة Next.js خاصة.
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createTRPCContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: createTRPCContext,
onError:
process.env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
);
}
: undefined,
});
export { handler as GET, handler as POST };هذا كل شيء. كل من GET و POST يُعالجان. الاستعلامات تمر عبر GET (مع مدخلات مُرمّزة في URL)، والتحويلات تمر عبر POST.
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();ملاحظة: نستورد AppRouter كـ نوع فقط. لا يتسرب كود الخادم إلى حزمة العميل.
// src/components/providers/TRPCProvider.tsx
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import superjson from "superjson";
function getBaseUrl() {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
})
);
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}// src/app/layout.tsx (الجزء ذو الصلة)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}// src/app/dashboard/page.tsx
"use client";
import { trpc } from "@/lib/trpc";
export default function DashboardPage() {
const { data: user, isLoading, error } = trpc.user.me.useQuery();
if (isLoading) return <DashboardSkeleton />;
if (error) return <ErrorDisplay message={error.message} />;
return (
<div>
<h1>مرحباً بعودتك، {user.name}</h1>
<p>عضو منذ {user.createdAt.toLocaleDateString()}</p>
</div>
);
}user.name هذا مكتوب بالكامل. إذا أخطأت في كتابته كـ user.nme، TypeScript يلتقطه فوراً. إذا غيّرت الخادم ليُعيد displayName بدلاً من name، كل استخدام في العميل سيُظهر خطأ تجميع. لا مفاجآت وقت التشغيل.
السياق والوسيط هما حيث ينتقل tRPC من "خدعة نوع أنيقة" إلى "إطار جاهز للإنتاج."
دالة السياق تعمل على كل طلب. إليك نسخة واقعية:
// src/server/trpc.ts
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
const session = await getServerSession(authOptions);
return {
session,
user: session?.user ?? null,
db: prisma,
headers: Object.fromEntries(opts.req.headers),
};
}
type Context = Awaited<ReturnType<typeof createTRPCContext>>;نمط الوسيط الأكثر شيوعاً هو فصل الإجراءات العامة عن المحمية:
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to perform this action",
});
}
return next({
ctx: {
// تجاوز نوع السياق — المستخدم لم يعد قابلاً للإلغاء
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);بعد تشغيل هذا الوسيط، ctx.user في أي protectedProcedure مضمون أن يكون غير فارغ. نظام النوع يفرض هذا. لا يمكنك الوصول عن طريق الخطأ إلى ctx.user.id في إجراء عام بدون أن يشتكي TypeScript.
يمكنك تركيب الوسطاء لتحكم وصول أكثر دقة:
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (ctx.user.role !== "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
});
}
return next({
ctx: {
user: ctx.user,
},
});
});
export const adminProcedure = t.procedure.use(isAdmin);الوسيط ليس للمصادقة فقط. إليك وسيط تسجيل الأداء:
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(`${type} بطيء ${path}: ${duration}ms`);
}
return result;
});
// تطبيق على جميع الإجراءات
export const publicProcedure = t.procedure.use(loggerMiddleware);import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, "10 s"),
});
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
const ip = ctx.headers["x-forwarded-for"] ?? "unknown";
const { success, remaining } = await ratelimit.limit(ip);
if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `تم تجاوز حد المعدل. حاول مرة أخرى لاحقاً.`,
});
}
return next();
});يستخدم tRPC مكتبة Zod للتحقق من المدخلات. هذا ليس زخرفة اختيارية — إنه الآلية التي تضمن أمان المدخلات على كل من العميل والخادم.
const postRouter = router({
create: protectedProcedure
.input(
z.object({
title: z.string().min(1, "العنوان مطلوب").max(200, "العنوان طويل جداً"),
content: z.string().min(10, "المحتوى يجب أن يكون 10 أحرف على الأقل"),
categoryId: z.string().uuid("معرف الفئة غير صالح"),
tags: z.array(z.string()).max(5, "5 وسوم كحد أقصى").default([]),
published: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// المدخل مكتوب بالكامل:
// {
// title: string;
// content: string;
// categoryId: string;
// tags: string[];
// published: boolean;
// }
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});إليك شيئاً دقيقاً: تحقق Zod يعمل على كلا الجانبين. على العميل، يتحقق tRPC من المدخل قبل إرسال الطلب. إذا كان المدخل غير صالح، الطلب لا يغادر المتصفح أبداً. على الخادم، نفس المخطط يتحقق مرة أخرى كإجراء أمني.
هذا يعني أنك تحصل على تحقق فوري من جانب العميل مجاناً:
"use client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
export function CreatePostForm() {
const [title, setTitle] = useState("");
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
onSuccess: () => {
utils.post.list.invalidate();
},
onError: (error) => {
// أخطاء Zod تصل منظمة
if (error.data?.zodError) {
const fieldErrors = error.data.zodError.fieldErrors;
// fieldErrors.title?: string[]
// fieldErrors.content?: string[]
console.log(fieldErrors);
}
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
createPost.mutate({
title,
content: "...",
categoryId: "...",
});
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
{createPost.error?.data?.zodError?.fieldErrors.title && (
<span className="text-red-500">
{createPost.error.data.zodError.fieldErrors.title[0]}
</span>
)}
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? "جارٍ الإنشاء..." : "إنشاء مقال"}
</button>
</form>
);
}// الاتحادات المميّزة
const searchInput = z.discriminatedUnion("type", [
z.object({
type: z.literal("user"),
query: z.string(),
includeInactive: z.boolean().default(false),
}),
z.object({
type: z.literal("post"),
query: z.string(),
category: z.string().optional(),
}),
]);
// مدخل الترقيم المعاد استخدامه عبر الإجراءات
const paginationInput = z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).max(100).default(20),
});
const postRouter = router({
infiniteList: publicProcedure
.input(
z.object({
...paginationInput.shape,
category: z.string().optional(),
sortBy: z.enum(["newest", "popular", "trending"]).default("newest"),
})
)
.query(async ({ ctx, input }) => {
const { cursor, limit, category, sortBy } = input;
const posts = await ctx.db.post.findMany({
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
where: category ? { categoryId: category } : undefined,
orderBy:
sortBy === "newest"
? { createdAt: "desc" }
: sortBy === "popular"
? { likes: "desc" }
: { score: "desc" },
});
let nextCursor: string | undefined;
if (posts.length > limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return {
posts,
nextCursor,
};
}),
});ليس كل إجراء يحتاج مدخلاً. الاستعلامات غالباً لا تحتاج:
const statsRouter = router({
// لا مدخل مطلوب
overview: publicProcedure.query(async ({ ctx }) => {
const [userCount, postCount, commentCount] = await Promise.all([
ctx.db.user.count(),
ctx.db.post.count(),
ctx.db.comment.count(),
]);
return { userCount, postCount, commentCount };
}),
// فلاتر اختيارية
detailed: publicProcedure
.input(
z
.object({
from: z.date().optional(),
to: z.date().optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const where = {
...(input?.from && { createdAt: { gte: input.from } }),
...(input?.to && { createdAt: { lte: input.to } }),
};
return ctx.db.post.groupBy({
by: ["categoryId"],
where,
_count: true,
});
}),
});معالجة أخطاء tRPC منظمة، وآمنة نوعياً، وتتكامل بسلاسة مع كل من دلالات HTTP وواجهة المستخدم على جانب العميل.
import { TRPCError } from "@trpc/server";
const postRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "المقال غير موجود",
});
}
if (post.authorId !== ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "يمكنك حذف مقالاتك فقط",
});
}
await ctx.db.post.delete({ where: { id: input.id } });
return { success: true };
}),
});أكواد أخطاء tRPC تتوافق مع أكواد حالة HTTP:
| كود tRPC | حالة HTTP | متى تستخدم |
|---|---|---|
BAD_REQUEST | 400 | مدخل غير صالح بخلاف تحقق Zod |
UNAUTHORIZED | 401 | غير مسجّل الدخول |
FORBIDDEN | 403 | مسجّل الدخول لكن صلاحيات غير كافية |
NOT_FOUND | 404 | المورد غير موجود |
CONFLICT | 409 | مورد مكرر |
TOO_MANY_REQUESTS | 429 | تجاوز حد المعدل |
INTERNAL_SERVER_ERROR | 500 | خطأ خادم غير متوقع |
هل تذكر مُنسّق الأخطاء من إعدادنا؟ إليك كيف يعمل عملياً:
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
// إضافة حقول مخصصة
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
},
};
},
});"use client";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
export function DeletePostButton({ postId }: { postId: string }) {
const utils = trpc.useUtils();
const deletePost = trpc.post.delete.useMutation({
onSuccess: () => {
toast.success("تم حذف المقال");
utils.post.list.invalidate();
},
onError: (error) => {
switch (error.data?.code) {
case "FORBIDDEN":
toast.error("ليس لديك صلاحية لحذف هذا المقال");
break;
case "NOT_FOUND":
toast.error("هذا المقال لم يعد موجوداً");
utils.post.list.invalidate();
break;
default:
toast.error("حدث خطأ ما. يرجى المحاولة مرة أخرى.");
}
},
});
return (
<button
onClick={() => deletePost.mutate({ id: postId })}
disabled={deletePost.isPending}
>
{deletePost.isPending ? "جارٍ الحذف..." : "حذف"}
</button>
);
}يمكنك إعداد معالج أخطاء شامل يلتقط جميع أخطاء tRPC غير المعالجة:
// في TRPCProvider الخاص بك
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// احتياطي شامل لأخطاء التحويل غير المعالجة
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") {
// إعادة التوجيه لتسجيل الدخول
window.location.href = "/login";
return;
}
toast.error(error.message);
}
},
},
},
})
);التحويلات هي حيث يتكامل tRPC حقاً بشكل جيد مع TanStack Query. لنلقِ نظرة على نمط واقعي: زر إعجاب مع تحديثات متفائلة.
// الخادم
const postRouter = router({
toggleLike: protectedProcedure
.input(z.object({ postId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.like.findUnique({
where: {
userId_postId: {
userId: ctx.user.id,
postId: input.postId,
},
},
});
if (existing) {
await ctx.db.like.delete({
where: { id: existing.id },
});
return { liked: false };
}
await ctx.db.like.create({
data: {
userId: ctx.user.id,
postId: input.postId,
},
});
return { liked: true };
}),
});المستخدم يضغط "إعجاب." لا تريد الانتظار 200ms لاستجابة الخادم لتحديث الواجهة. التحديثات المتفائلة تحل هذا: حدّث الواجهة فوراً، ثم تراجع إذا رفض الخادم.
"use client";
import { trpc } from "@/lib/trpc";
export function LikeButton({ postId, initialLiked, initialCount }: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const utils = trpc.useUtils();
const toggleLike = trpc.post.toggleLike.useMutation({
onMutate: async ({ postId }) => {
// إلغاء إعادة الجلب الصادرة حتى لا تكتب فوق تحديثنا المتفائل
await utils.post.byId.cancel({ id: postId });
// لقطة للقيمة السابقة
const previousPost = utils.post.byId.getData({ id: postId });
// تحديث ذاكرة التخزين المؤقت بتفاؤل
utils.post.byId.setData({ id: postId }, (old) => {
if (!old) return old;
return {
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
};
});
// إعادة اللقطة للتراجع
return { previousPost };
},
onError: (_error, { postId }, context) => {
// التراجع للقيمة السابقة عند الخطأ
if (context?.previousPost) {
utils.post.byId.setData({ id: postId }, context.previousPost);
}
},
onSettled: (_data, _error, { postId }) => {
// إعادة الجلب دائماً بعد الخطأ أو النجاح لضمان حالة الخادم
utils.post.byId.invalidate({ id: postId });
},
});
return (
<button
onClick={() => toggleLike.mutate({ postId })}
className={initialLiked ? "text-red-500" : "text-gray-400"}
>
♥ {initialCount}
</button>
);
}النمط هو نفسه دائماً:
onMutate: إلغاء الاستعلامات، لقطة للبيانات الحالية، تطبيق التحديث المتفائل، إعادة اللقطة.onError: التراجع باستخدام اللقطة.onSettled: إبطال الاستعلام حتى يعيد الجلب من الخادم، بغض النظر عن النجاح أو الخطأ.هذه الرقصة الثلاثية تضمن أن الواجهة دائماً متجاوبة ومتسقة في النهاية مع الخادم.
بعد التحويل، غالباً ما تحتاج لتحديث البيانات المرتبطة. useUtils() الخاص بـ tRPC يجعل هذا مريحاً:
const createComment = trpc.comment.create.useMutation({
onSuccess: (_data, variables) => {
// إبطال قائمة تعليقات المقال
utils.comment.listByPost.invalidate({ postId: variables.postId });
// إبطال المقال نفسه (عدد التعليقات تغيّر)
utils.post.byId.invalidate({ id: variables.postId });
// إبطال جميع قوائم المقالات (عدد التعليقات في عروض القائمة)
utils.post.list.invalidate();
},
});افتراضياً، tRPC مع httpBatchLink يجمع طلبات متزامنة متعددة في استدعاء HTTP واحد. إذا عُرض مكوّن وأطلق ثلاثة استعلامات:
function Dashboard() {
const user = trpc.user.me.useQuery();
const posts = trpc.post.list.useQuery({ limit: 10 });
const stats = trpc.stats.overview.useQuery();
// ...
}هذه الاستعلامات الثلاثة تُجمّع تلقائياً في طلب HTTP واحد: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...
الخادم يعالج الثلاثة، ويُعيد الثلاث نتائج في استجابة واحدة، وTanStack Query يوزع النتائج على كل خطاف. لا حاجة لإعداد.
يمكنك تعطيل التجميع لاستدعاءات محددة إذا لزم الأمر:
// في مزوّدك، استخدم splitLink لتوجيه إجراءات محددة بشكل مختلف
import { splitLink, httpBatchLink, httpLink } from "@trpc/client";
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.path === "post.infiniteList",
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
maxURLLength: 2048,
}),
}),
],
})
);للميزات في الوقت الحقيقي، يدعم tRPC الاشتراكات عبر WebSockets. هذا يتطلب خادم WebSocket منفصلاً (Next.js لا يدعم WebSockets أصلياً في معالجات المسار).
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
// باعث أحداث في الذاكرة (استخدم Redis pub/sub في الإنتاج)
import { EventEmitter } from "events";
const eventEmitter = new EventEmitter();
export const notificationRouter = router({
onNew: protectedProcedure.subscription(({ ctx }) => {
return observable<Notification>((emit) => {
const handler = (notification: Notification) => {
if (notification.userId === ctx.user.id) {
emit.next(notification);
}
};
eventEmitter.on("notification", handler);
return () => {
eventEmitter.off("notification", handler);
};
});
}),
// تحويل يُحرّك إشعاراً
markAsRead: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const notification = await ctx.db.notification.update({
where: { id: input.id, userId: ctx.user.id },
data: { readAt: new Date() },
});
return notification;
}),
});على العميل:
"use client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
trpc.notification.onNew.useSubscription(undefined, {
onData: (notification) => {
setNotifications((prev) => [notification, ...prev]);
toast.info(notification.message);
},
onError: (error) => {
console.error("خطأ في الاشتراك:", error);
},
});
return (
<div>
<span className="badge">{notifications.length}</span>
{/* واجهة قائمة الإشعارات */}
</div>
);
}لنقل WebSocket، تحتاج عملية خادم مخصصة. إليك إعداداً أساسياً مع مكتبة ws:
// ws-server.ts (عملية منفصلة)
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "./server/routers/_app";
import { createTRPCContext } from "./server/trpc";
const wss = new WebSocketServer({ port: 3001 });
const handler = applyWSSHandler({
wss,
router: appRouter,
createContext: createTRPCContext,
});
wss.on("connection", (ws) => {
console.log(`تم فتح اتصال (${wss.clients.size} إجمالي)`);
ws.once("close", () => {
console.log(`تم إغلاق اتصال (${wss.clients.size} إجمالي)`);
});
});
process.on("SIGTERM", () => {
handler.broadcastReconnectNotification();
wss.close();
});
console.log("خادم WebSocket يستمع على ws://localhost:3001");والعميل يحتاج wsLink للاشتراكات:
import { wsLink, createWSClient } from "@trpc/client";
const wsClient = createWSClient({
url: "ws://localhost:3001",
});
// استخدم splitLink لتوجيه الاشتراكات عبر WebSocket
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.type === "subscription",
true: wsLink({ client: wsClient, transformer: superjson }),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
}),
],
})
);tRPC لا يتعامل مع رفع الملفات أصلياً. إنه بروتوكول JSON-RPC — البيانات الثنائية ليست في اختصاصه. لكن يمكنك بناء تدفق رفع آمن نوعياً بدمج tRPC مع عناوين URL الموقّعة مسبقاً.
النمط:
// src/server/routers/upload.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export const uploadRouter = router({
getPresignedUrl: protectedProcedure
.input(
z.object({
filename: z.string().min(1).max(255),
contentType: z.string().regex(/^(image|application)\//),
size: z.number().max(10 * 1024 * 1024, "يجب أن يكون الملف أقل من 10MB"),
})
)
.mutation(async ({ ctx, input }) => {
const key = `uploads/${ctx.user.id}/${crypto.randomUUID()}-${input.filename}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: input.contentType,
ContentLength: input.size,
});
const presignedUrl = await getSignedUrl(s3, command, {
expiresIn: 300, // 5 دقائق
});
// تخزين الرفع المعلق في قاعدة البيانات
const upload = await ctx.db.upload.create({
data: {
key,
userId: ctx.user.id,
filename: input.filename,
contentType: input.contentType,
size: input.size,
status: "PENDING",
},
});
return {
uploadId: upload.id,
presignedUrl,
key,
};
}),
confirmUpload: protectedProcedure
.input(z.object({ uploadId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const upload = await ctx.db.upload.findUnique({
where: { id: input.uploadId, userId: ctx.user.id },
});
if (!upload) {
throw new TRPCError({ code: "NOT_FOUND" });
}
// التحقق من وجود الملف فعلاً في S3
// (اختياري لكن موصى به)
const confirmed = await ctx.db.upload.update({
where: { id: upload.id },
data: { status: "CONFIRMED" },
});
return {
url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${confirmed.key}`,
};
}),
});على العميل:
"use client";
import { trpc } from "@/lib/trpc";
import { useState, useCallback } from "react";
export function FileUpload() {
const [uploading, setUploading] = useState(false);
const getPresignedUrl = trpc.upload.getPresignedUrl.useMutation();
const confirmUpload = trpc.upload.confirmUpload.useMutation();
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
// الخطوة 1: الحصول على عنوان URL موقّع من tRPC
const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type,
size: file.size,
});
// الخطوة 2: الرفع مباشرة إلى S3
const uploadResponse = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("فشل الرفع");
}
// الخطوة 3: تأكيد الرفع عبر tRPC
const { url } = await confirmUpload.mutateAsync({ uploadId });
toast.success(`تم رفع الملف: ${url}`);
} catch (error) {
toast.error("فشل الرفع. يرجى المحاولة مرة أخرى.");
} finally {
setUploading(false);
}
},
[getPresignedUrl, confirmUpload]
);
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
accept="image/*"
/>
{uploading && <p>جارٍ الرفع...</p>}
</div>
);
}التدفق بأكمله آمن نوعياً. نوع استجابة العنوان الموقّع، ونوع معرّف الرفع، واستجابة التأكيد — كلها مستنتجة من تعريفات الخادم. إذا أضفت حقلاً جديداً لاستجابة العنوان الموقّع، العميل يعرف عنه فوراً.
مع Next.js App Router، غالباً ما تريد جلب البيانات في مكوّنات الخادم. يدعم tRPC هذا من خلال المُستدعين من جانب الخادم:
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
// الاستخدام في مكوّن خادم
// src/app/posts/[id]/page.tsx
import { createTRPCContext } from "@/server/trpc";
import { createCaller } from "@/server/trpc";
export default async function PostPage({
params,
}: {
params: { id: string };
}) {
const ctx = await createTRPCContext({
req: new Request("http://localhost"),
resHeaders: new Headers(),
});
const caller = createCaller(ctx);
const post = await caller.post.byId({ id: params.id });
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
{/* مكوّنات العميل للأجزاء التفاعلية */}
<LikeButton postId={post.id} initialLiked={post.liked} />
<CommentSection postId={post.id} />
</article>
);
}هذا يمنحك أفضل ما في العالمين: بيانات أولية معروضة من الخادم مع أمان نوعي كامل، وتفاعلية من جانب العميل للتحويلات والميزات في الوقت الحقيقي.
الاختبار مباشر لأن الإجراءات مجرد دوال. لا تحتاج لتشغيل خادم HTTP.
// src/server/routers/user.test.ts
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "./routers/_app";
import { createCaller } from "./trpc";
describe("موجّه المستخدم", () => {
it("يُعيد ملف المستخدم الحالي", async () => {
const caller = createCaller({
user: { id: "user-1", email: "test@example.com", role: "USER" },
db: prismaMock,
session: mockSession,
headers: {},
});
prismaMock.user.findUnique.mockResolvedValue({
id: "user-1",
name: "Test User",
email: "test@example.com",
image: null,
createdAt: new Date("2026-01-01"),
});
const result = await caller.user.me();
expect(result).toEqual({
id: "user-1",
name: "Test User",
email: "test@example.com",
image: null,
createdAt: new Date("2026-01-01"),
});
});
it("يرمي UNAUTHORIZED للطلبات غير المصادق عليها", async () => {
const caller = createCaller({
user: null,
db: prismaMock,
session: null,
headers: {},
});
await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
});
it("يتحقق من المدخل مع Zod", async () => {
const caller = createCaller({
user: { id: "user-1", email: "test@example.com", role: "USER" },
db: prismaMock,
session: mockSession,
headers: {},
});
await expect(
caller.user.updateProfile({
name: "", // الحد الأدنى للطول 1
})
).rejects.toThrow();
});
});لا محاكاة لطبقات HTTP، لا supertest، لا مطابقة مسارات. فقط استدعِ الدالة وأكّد النتيجة. هذه واحدة من مزايا tRPC غير المقدّرة: الاختبار بسيط بشكل تافه لأن طبقة النقل هي تفصيل تنفيذي.
tRPC ليس حلاً شاملاً. إليك أين ينهار:
إذا كنت تبني API يستهلكه مطورون خارجيون، tRPC هو الخيار الخاطئ. المستهلكون الخارجيون ليس لديهم وصول لأنواع TypeScript الخاصة بك. يحتاجون عقداً موثّقاً ومستقراً — OpenAPI/Swagger لـ REST، أو مخطط GraphQL. أمان نوع tRPC يعمل فقط عندما يشترك العميل والخادم في نفس قاعدة كود TypeScript.
إذا كان تطبيق الهاتف مكتوباً بـ Swift، أو Kotlin، أو Dart، فإن tRPC لا يقدم شيئاً. الأنواع لا تعبر حدود اللغات. يمكنك نظرياً توليد مواصفة OpenAPI من مسارات tRPC باستخدام trpc-openapi، لكن عند تلك النقطة أنت تضيف التكلفة الإجرائية من جديد. فقط استخدم REST من البداية.
tRPC يفترض قاعدة كود TypeScript واحدة. إذا كانت خلفيتك مقسمة عبر خدمات متعددة بلغات مختلفة، tRPC لا يمكنه المساعدة في الاتصال بين الخدمات. استخدم gRPC، أو REST، أو قوائم الرسائل لذلك.
إذا كانت الواجهة الأمامية والخلفية في مستودعات منفصلة بخطوط نشر منفصلة، تفقد ميزة tRPC الأساسية. مشاركة النوع تتطلب مستودعاً أحادياً أو حزمة مشتركة. يمكنك نشر نوع AppRouter كحزمة npm، لكن الآن لديك مشكلة إصدارات يتعامل معها REST + OpenAPI بشكل أكثر طبيعية.
إذا كنت تحتاج رؤوس تخزين HTTP المؤقت، أو التفاوض على المحتوى، أو ETags، أو ميزات REST محددة أخرى، فإن تجريد tRPC فوق HTTP سيقاومك. tRPC يتعامل مع HTTP كتفصيل نقل، ليس كميزة.
إليك كيف أقرر:
| السيناريو | التوصية |
|---|---|
| تطبيق TypeScript fullstack في نفس المستودع | tRPC — أقصى فائدة، أقل تكلفة |
| أداة داخلية / لوحة إدارة | tRPC — سرعة التطوير هي الأولوية |
| API عام لمطورين خارجيين | REST + OpenAPI — المستهلكون يحتاجون وثائق، ليس أنواعاً |
| عملاء هاتف + ويب (هاتف غير TS) | REST أو GraphQL — يحتاجون عقوداً محايدة للغة |
| كثيف الوقت الحقيقي (محادثة، ألعاب) | اشتراكات tRPC أو WebSockets خام حسب التعقيد |
| فرق واجهة أمامية/خلفية منفصلة | GraphQL — المخطط هو العقد بين الفرق |
بعض الأشياء التي تعلمتها من تشغيل tRPC في الإنتاج وليست في التوثيق:
حافظ على الموجّهات صغيرة. ملف موجّه واحد يجب ألا يتجاوز 200 سطر. قسّم حسب المجال: userRouter، postRouter، billingRouter. كل واحد في ملفه.
استخدم createCallerFactory للاستدعاءات من جانب الخادم. لا تلجأ لـ fetch عند استدعاء API الخاص بك من مكوّن خادم. مصنع المُستدعي يمنحك نفس الأمان النوعي بدون تكلفة HTTP.
لا تبالغ في تحسين التجميع. httpBatchLink الافتراضي كافٍ دائماً تقريباً. رأيت فرقاً تقضي أياماً في إعداد تكوينات splitLink لمكاسب هامشية. حلّل الأداء أولاً.
اضبط staleTime في QueryClient. الـ staleTime الافتراضي 0 يعني أن كل حدث تركيز يُحرّك إعادة جلب. اضبطه على شيء معقول (30 ثانية إلى 5 دقائق) بناءً على متطلبات حداثة البيانات.
استخدم superjson من اليوم الأول. إضافته لاحقاً يعني ترحيل كل عميل وخادم في وقت واحد. إنه إعداد سطر واحد يحميك من أخطاء سلسلة Date.
حدود الأخطاء صديقتك. غلّف أقسام الصفحة الكثيفة بـ tRPC في حدود أخطاء React. استعلام واحد فاشل يجب ألا يُسقط الصفحة بأكملها.
"use client";
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert" className="p-4 bg-red-50 rounded-lg">
<p className="font-medium text-red-800">حدث خطأ ما</p>
<pre className="text-sm text-red-600 mt-2">{error.message}</pre>
<button
onClick={resetErrorBoundary}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded"
>
حاول مرة أخرى
</button>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>لوحة التحكم</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RecentPosts />
</ErrorBoundary>
</div>
);
}tRPC ليس بديلاً لـ REST أو GraphQL. إنه أداة مختلفة لحالة محددة: عندما تتحكم في كل من العميل والخادم، وكلاهما TypeScript، وتريد أقصر مسار ممكن من "غيّرت الخلفية" إلى "الواجهة الأمامية تعرف عن ذلك."
في تلك الحالة، لا شيء آخر يقترب. لا توليد كود، لا ملفات مخططات، لا انحراف. فقط TypeScript يفعل ما يجيده أفضل: التقاط الأخطاء قبل وصولها للإنتاج.
المقايضة واضحة: تتنازل عن التشغيل البيني على مستوى البروتوكول (لا عملاء غير TypeScript) مقابل سرعة تطوير وأمان في وقت التجميع يصعب تحقيقه بأي طريقة أخرى.
لمعظم تطبيقات TypeScript fullstack، هذه مقايضة تستحق القيام بها.