تخطّى إلى المحتوى الرئيسي
رفع الملفات في Next.js App Router: ملاحظاتنا الميدانية حول Vercel Blob والرفع من المتصفح والاستدعاء الذي لا يعمل على localhost

رفع الملفات في Next.js App Router: ملاحظاتنا الميدانية حول Vercel Blob والرفع من المتصفح والاستدعاء الذي لا يعمل على localhost

August 12, 2026
هندسة الأنظمة المتكاملة
7 دقيقة قراءة

أهم النقاط

• Vercel Blob هي خدمة تخزين الكائنات من Vercel. حزمة @vercel/blob توفّر الدوال put() و head() و list() و copy() و del() لكود الخادم، إضافة إلى نقطة دخول منفصلة باسم @vercel/blob/client للرفع من المتصفح.

• الرفع من المتصفح هو مصافحة من ثلاث خطوات: المتصفح يطلب من route handler رمزًا لمرة واحدة، ثم يرسل بايتات الملف مباشرة إلى التخزين، ثم يستدعي التخزينُ نفسَ الـ route handler عبر onUploadCompleted.

• onUploadCompleted هو طلب HTTP وارد من بنية Vercel إلى رابط نشرك العام، ولذلك لا يصل إلى localhost. بدون نفق عام ينجح الرفع ولا يُكتب صف قاعدة البيانات أبدًا.

• منذ الإصدار الأول من @vercel/blob أصبحت القيمة الافتراضية للخيار addRandomSuffix هي false، لذا يفشل رفع نفس المسار مرتين بالخطأ HTTP 409 ما لم تمرر allowOverwrite: true.

• الخيار allowedContentTypes يتحقق من نوع MIME الذي يعلنه المتصفح، والمتصفح يشتق هذه القيمة من امتداد الملف. تحقّق من البايتات السحرية للملف على الخادم قبل الوثوق به.

لماذا لا نرفع الملف عبر route handler ببساطة؟

الرفع عبر route handler هو الخيار الصحيح للملفات الصغيرة والخيار الخاطئ للملفات الكبيرة، لأن كل بايت يمر عبر دالة serverless محسوبة التكلفة ولها حد زمني. دوال Vercel تقبل الآن أجسام طلبات حتى 100 ميغابايت بدلًا من السقف القديم 4.5 ميغابايت، لذا فتمرير الملفات متوسطة الحجم عبر الخادم صار عمليًا في 2026 — لكنه ببساطة لا يتوسع بعد ذلك.

حين يمرر فريقنا ملفًا عبر الخادم فإننا نمرر request.body مباشرة إلى put(). استدعاء await request.formData() يخزّن الملف كاملًا في ذاكرة الدالة قبل أن يصل بايت واحد إلى التخزين، وهكذا يتحول ملف PDF بحجم 90 ميغابايت إلى خطأ نفاد ذاكرة.

الموازنة في سطر واحد: route handler محدود بسقف 100 ميغابايت لجسم الطلب، ومحاسَب على مدة النقل كاملة، ويعمل من طرف إلى طرف على localhost. أما الرفع من المتصفح فيتعامل مع ملفات بحجم غيغابايتات، ويحاسِب على استدعاءين قصيرين فقط، ويمنحك تقارير التقدم مجانًا، لكنه لا يستطيع إكمال خطوته الأخيرة على localhost.

كيف يعمل الرفع من المتصفح عبر Vercel Blob فعليًا؟

الرفع من المتصفح عبر Vercel Blob يجري في ثلاث خطوات، ومعرفة الخطوة التي أنت فيها تفسّر معظم الأخطاء هنا. الخطوة الأولى: المتصفح يرسل POST إلى route handler لطلب رمز رفع محدود الصلاحية. الخطوة الثانية: المتصفح يرسل بايتات الملف مباشرة إلى Vercel Blob. الخطوة الثالثة: Vercel Blob يرسل POST إلى نفس الـ route handler ليبلغه بانتهاء الرفع.

في جهة العميل تأخذ الدالة upload() من @vercel/blob/client المسار وكائن File ورابط handleUploadUrl الذي يشير إلى مسارك. وفي جهة الخادم يغلّف route handler واحد الدالة handleUpload ويزوّدها بردّي نداء: onBeforeGenerateToken للخطوة الأولى و onUploadCompleted للخطوة الثالثة.

أهم تفصيلة على الإطلاق أن onUploadCompleted لا يملك جلسة مستخدم. تستدعيه بنية Vercel لا متصفح المستخدم، فلا توجد كوكيز ولا ترويسات تتحكم بها. كل ما تحتاجه الخطوة الثالثة يجب تسلسله داخل tokenPayload أثناء الخطوة الأولى. استدعاء دالة auth() داخل onUploadCompleted يعيد null في كل مرة.

لماذا لا يعمل onUploadCompleted على localhost؟

onUploadCompleted لا يعمل على localhost لأنه طلب HTTP وارد من Vercel Blob إلى الرابط العام لتطبيقك، والعنوان http://localhost:3000 غير قابل للوصول من الإنترنت العام. صورة الفشل خادعة: الدالة upload() تنجح، والملف موجود فعلًا في التخزين، والواجهة تعرض حالة نجاح — بينما يبقى جدول قاعدة البيانات فارغًا إلى الأبد.

الحل هو تشغيل خادم التطوير خلف نفق عام مثل ngrok وفتح التطبيق عبر اسم النفق لا عبر localhost، لأن handleUpload يشتق رابط الاستدعاء من الطلب الوارد. ونتجنب عمدًا الحل الالتفافي القائم على جعل المتصفح يؤكد الرفع عبر نقطة نهاية ثانية: فهو غير موثوق بطبيعته لأن إغلاق التبويب يُضيع الصف، كما أنه ينشئ مسارَي كود يكتبان السجل نفسه.

كيف نمنع المستخدمين من الكتابة فوق ملفات بعضهم؟

في Vercel Blob يمثّل المسار هوية الكائن، لذا يتصادم رفعان يتشاركان نفس المسار. ومنذ الإصدار الأول من @vercel/blob صارت القيمة الافتراضية لـ addRandomSuffix هي false، ما يعني أن رفع invoice.pdf مرة ثانية يعيد الخطأ HTTP 409 بدل استبدال الكائن الأول بصمت. هذا الافتراضي هو الآمن.

هناك استراتيجيتان متماسكتان. الأولى: ضبط addRandomSuffix: true ليضيف Vercel رمزًا عشوائيًا إلى كل مسار، فتستحيل التصادمات مقابل ظهور كائنات مكررة عند إعادة الرفع. والثانية: بناء مسار حتمي ضمن نطاق المستخدم مثل u/{userId}/{documentId}/{filename} مع تمرير allowOverwrite: true لجعل إعادة الرفع متكافئة. الفخ في الاستراتيجية الثانية أن العميل هو من يختار المسار، فجمع مسار يتحكم به المستخدم مع allowOverwrite: true يتيح لحساب أن يمحو ملف حساب آخر. تحقّق من بادئة المسار داخل onBeforeGenerateToken مقابل جلسة المستخدم الموثقة.

كيف نتحقق من نوع الملف والمتصفح قادر على الكذب؟

الخيار allowedContentTypes يتحقق من ترويسة Content-Type التي يعلنها المتصفح، والمتصفح يشتقها في معظم الأنظمة من امتداد الملف. إعادة تسمية payload.exe إلى avatar.png تكفي لجعل Chrome يعلن image/png، لذا فـ allowedContentTypes حارس تجربة استخدام لا ضابط أمني.

التحقق الحقيقي يجري داخل onUploadCompleted بعد وجود البايتات: اجلب البايتات الأولى فقط بترويسة Range، وقارنها بالرقم السحري للصيغة، واستدعِ del() على الكائن عند عدم التطابق. الجلب الجزئي يبقي الفحص رخيصًا حتى للملفات الضخمة. ونقطة مرتبطة تستحق القول بوضوح: أي كائن يُنشأ بـ access: 'public' يمكن لأي شخص يملك الرابط قراءته، والرابط غير القابل للتخمين تعمية لا تحكم بالوصول. يدعم Vercel Blob التخزين الخاص، فالملفات المقيدة يجب أن تُرفع بوصول خاص خلف مسار تنزيل يفحص الجلسة.

ماذا يحدث للكائن حين تفشل الكتابة في قاعدة البيانات؟

لا يحدث للكائن شيء — يبقى في التخزين ويستمر في تكبيدك تكلفة. التخزين وقاعدة البيانات نظامان مستقلان بلا معاملة مشتركة، لذا يترك أي فشل داخل onUploadCompleted كائنًا لا يشير إليه أي صف. وعبر أشهر من إعادات المحاولة والمهل المنتهية تكبر هذه المجموعة اليتيمة بهدوء.

يعتمد فريقنا محاسبة على مرحلتين. في الخطوة الأولى ندرج صفًا بحالة pending مفتاحه المسار الذي نوشك أن نصرّح به، وفي الخطوة الثالثة نحوّله إلى ready. ثم تمر مهمة تنظيف مجدولة على التخزين عبر list() ومؤشر cursor لتحذف كل ما مضى عليه أكثر من يوم ولا يطالب به أي صف. حد العمر ليس اختياريًا: بدونه تحذف المهمة كائنات ما زال onUploadCompleted الخاص بها في الطريق، وهي علة أسوأ من التسريب الذي كُتبت لعلاجه.

كيف نعرض تقدمًا حقيقيًا للملفات الضخمة؟

تقبل كل من upload() و put() ردّ نداء باسم onUploadProgress يستقبل loaded و total و percentage، لذا لا يحتاج شريط التقدم الدقيق إلى أي كود بث مخصص. وضبط multipart: true يقسّم الملف إلى أجزاء تُرفع بالتوازي مع إعادة محاولة لكل جزء، وهو ما يجعل الرفع الكبير ينجو من اتصال غير مستقر.

هناك سلوكان يستحقان التخطيط لهما. مع تفعيل multipart يتقدم المؤشر على شكل قفزات مرئية كلما اكتمل جزء بدل التحرك السلس، فيبدو الشريط المتحرك ببساطة وكأنه معطّل. كما يضيف multipart طلبًا لكل جزء، وهو هدر خالص لصورة رمزية بحجم 2 ميغابايت، لذا نربط تفعيله بحجم الملف بدل تعميمه. وللإلغاء تقبل upload() الخيار abortSignal، وإلغاء الرفع في منتصفه يعني ببساطة أن onUploadCompleted لن يعمل — وهي حالة تغطيها مهمة التنظيف أصلًا.

الأسئلة الشائعة

س: هل ما زلت بحاجة إلى route handler إذا استخدمت الرفع من المتصفح؟

ج: نعم. تعيش الدالة handleUpload داخل route handler يستدعيه المتصفح للحصول على رمز لمرة واحدة ويستدعيه Vercel Blob عند انتهاء الرفع. بايتات الملف وحدها هي التي تتجاوزه.

س: هل رابط Vercel Blob العام آمن لأنه غير قابل للتخمين؟

ج: لا. أي كائن يُنشأ بـ access: 'public' يمكن لأي شخص يملك الرابط قراءته ويُخزَّن مؤقتًا في شبكة التوزيع. استخدم التخزين الخاص في Vercel Blob مع مسار تنزيل يفحص الجلسة لأي محتوى حساس.

س: متى ينبغي تفعيل multipart: true؟

ج: حين تكون إعادة محاولة نقل فاشل مكلفة، أي تقريبًا للملفات من 100 ميغابايت فأكثر. multipart يضيف طلبًا لكل جزء، فالعبء لا يستحق ذلك مع الصور الصغيرة.

س: ماذا يحدث إذا أغلق المستخدم التبويب أثناء الرفع؟

ج: يتوقف النقل ولا يعمل onUploadCompleted، فلا يُنشأ أي صف في قاعدة البيانات. مهمة التنظيف هي التي تزيل البقايا الجزئية، وهذا سبب إضافي لبنائها مبكرًا.

س: هل يمكن إعادة تسمية الكائن أو نقله بعد الرفع؟

ج: ليس في مكانه. استخدم copy() من @vercel/blob لكتابة الكائن في مسار جديد ثم del() للأصل. ولأن المسار هو هوية الكائن، اختر نظام التسمية قبل أن تصل إلى مليون كائن.