صُمِّمت معظم أطر React الشاملة في الأصل لخوادم Node.js، ثم جرى تكييفها لاحقًا كي تعمل على الحافة. أما RedwoodSDK فيسلك الطريق المعاكس. يبدأ كإضافة Vite، ويستهدف Cloudflare Workers بوصفه بيئة التشغيل الوحيدة، ويمنحك كائنَي Request وResponse الخامّين بدلًا من إخفائهما خلف اصطلاحات. أما مكوّنات الخادم في React ودوال الخادم والبثّ والزمن الحقيقي فتقوم جميعها فوق هذا الأساس لا بجانبه.
في هذا الدرس ستبني تطبيق Snippets، وهو تطبيق صغير لمشاركة الكود: صفحة رئيسية تعرض المقتطفات من قاعدة بيانات Cloudflare D1، وصفحة تفاصيل على مسار ديناميكي، ومكوّن عميل يرسل مقتطفات جديدة عبر دالة خادم، ووسيط حماية يحرس مسار الإدارة، وجلسات كوكيز مدعومة بكائن Durable Object. ثم ستنشر كل ذلك على Cloudflare بأمر واحد.
في نهاية الدرس ستفهم أين يقع الحدّ الفاصل بين الخادم والعميل في RedwoodSDK، وكيف يتدفّق كائن ctx عبر الطلب، ولماذا تُعدّ عبارة "إنه مجرّد Worker" أهم قرار تصميمي في هذا الإطار.
لماذا RedwoodSDK، ومتى تلجأ إليه
RedwoodSDK ليس بديلًا عامًّا عن Next.js. إنه يراهن على فكرة محدَّدة: ما دمت ستنشر على Cloudflare على أي حال، فمن الأجدر بالإطار أن يكشف المنصّة لا أن يجرّدها. ولهذا الرهان تبعات تستحق الفهم قبل الالتزام به.
- لا توجد دورة حياة خفيّة للطلب. تطبيقك هو استدعاء واحد لـ
defineAppيستقبل طلبًا ويعيد استجابة. المسارات دوال. الوسيطات دوال. إن كنت تقرأ Worker عاديًّا، فأنت تقرأ تطبيق RedwoodSDK. - أوّليات المنصّة مواطنة من الدرجة الأولى، لا محوّلات. يجري الوصول إلى D1 وR2 وKV وQueues وDurable Objects عبر الربط القياسي
envالقادم منcloudflare:workers. لا توجد طبقة توافق تترجم واجهة Node إلى واجهة Cloudflare. - بيئة التطوير المحلية تحاكي الإنتاج. لأن
vite devيشغّل شيفرتك داخل بيئة Workers الفعلية عبر Miniflare، فإن قاعدة D1 التي تستعلم عنها محليًّا تتصرّف كقاعدة D1 التي تستعلم عنها في الإنتاج. - مستند HTML ملكك أنت. لا يوجد تخطيط جذري سحري. أنت تكتب مكوّن
Documentالذي يُخرج الوسوم بنفسك.
الجأ إلى RedwoodSDK حين تكون Cloudflare هي وجهة النشر لديك وتريد تحكّمًا كاملًا في الطلبات والاستجابات والبثّ. والجأ إلى إطار أكبر حين تحتاج إلى منظومة إضافات واسعة، أو تحسينًا مدمجًا للصور، أو القدرة على الانتقال إلى مضيف آخر في الربع القادم دون إعادة كتابة.
المتطلبات المسبقة
قبل أن تبدأ، تأكّد من توفّر:
- Node.js 20 أو أحدث وpnpm (يعمل npm أيضًا، لكن توثيق RedwoodSDK يعتمد pnpm)
- حساب Cloudflare مجاني — تحتاجه لإنشاء قاعدة D1 وللنشر
- إلمام بأساسيات React 19: المكوّنات والخصائص والخطّافات
- فهم عملي لماهية مكوّنات الخادم في React: مكوّنات تُصيَّر على الخادم ولا تُرسَل شيفرتها إلى المتصفّح أبدًا
- محرّر شيفرة وطرفية
لست بحاجة إلى خبرة سابقة بـ Cloudflare Workers. يقدّم الدرس كل ربط عند الحاجة إليه.
ما الذي ستبنيه
Snippets هو Worker واحد يعرض أربعة مسارات:
| المسار | النوع | وظيفته |
|---|---|---|
/ | مكوّن خادم | يعرض كل المقتطفات من D1 |
/s/:key | مكوّن خادم | يصيّر مقتطفًا واحدًا حسب مفتاحه |
/new | مكوّن خادم + جزيرة عميل | نموذج يستدعي دالة خادم |
/admin | مسار محروس | يحجبه وسيط ما لم توجد جلسة |
مجموع شيفرة التطبيق بضع مئات من الأسطر، وكل سطر منها يعمل على شبكة Cloudflare الطرفية.
الخطوة 1: تجهيز المشروع
يوفّر RedwoodSDK مولّد قوالب بدء. أنشئ المشروع وثبّت الاعتماديات:
npx create-rwsdk snippets
cd snippets
pnpm install
pnpm devافتح عنوان localhost المطبوع. من المفترض أن ترى صفحة البداية. الآن تأمّل ما أنتجه المولّد — الأجزاء المهمّة قليلة:
snippets/
├── src/
│ ├── worker.tsx # نقطة الدخول: هنا يوجد defineApp
│ ├── client.tsx # نقطة الترطيب لمكوّنات العميل
│ └── app/
│ ├── Document.tsx # قوقعة HTML التي تتحكّم بها
│ └── pages/
│ └── Home.tsx
├── wrangler.jsonc # إعدادات Cloudflare والروابط
└── vite.config.mts
هذا هو سطح الإطار كلّه. لا اصطلاح لمجلّد app/، ولا موجّه قائم على الملفات، ولا بيان بناء عليك التفكير فيه.
الخطوة 2: فهم worker.tsx والمستند
افتح src/worker.tsx. هذا الملف هو معالج الطلبات لتطبيقك بأكمله:
// src/worker.tsx
import { defineApp } from "rwsdk/worker";
import { route, render } from "rwsdk/router";
import { Document } from "@/app/Document";
import { Home } from "@/app/pages/Home";
export default defineApp([
render(Document, [
route("/", Home),
]),
]);ثلاث دوال تنجز العمل كلّه:
defineApp(handlers)تأخذ مصفوفة من المعالجات وتعيد Worker. تعمل المعالجات من الأعلى إلى الأسفل.route(path, handler)تطابق مسارًا. ويمكن للمعالج أن يعيد عنصر React، أو كائنResponse، أو أي شيء بينهما.render(Document, routes)تغلّف مجموعة مسارات بحيث يُصيَّر أي عنصر React تعيده داخل مستند HTML المعطى ويُبثّ إلى المتصفّح.
الفكرة الجوهرية أن معالجات route دوال عادية تعيد استجابات عادية. لا شيء يجبرك على استخدام React:
export default defineApp([
// استجابة نصية صرفة — لا علاقة لـ React بها
route("/health", () => new Response("ok")),
// استجابة XML
route("/sitemap.xml", async () => {
const xml = await buildSitemap();
return new Response(xml, {
status: 200,
headers: { "Content-Type": "application/xml" },
});
}),
// إعادة توجيه
route("/docs", () =>
new Response(null, {
status: 301,
headers: { Location: "https://docs.rwsdk.com" },
})
),
// مسارات React، مغلَّفة داخل المستند
render(Document, [route("/", Home)]),
]);الآن افتح src/app/Document.tsx. خلافًا للتخطيط الجذري في Next.js، هذا حرفيًّا هو قوقعة الصفحة، وأنت تكتب كل وسم فيها:
// src/app/Document.tsx
export const Document = ({ children }: { children: React.ReactNode }) => (
<html lang="ar" dir="rtl">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Snippets</title>
<link rel="modulepreload" href="/src/client.tsx" />
</head>
<body>
<div id="root">{children}</div>
<script>import("/src/client.tsx")</script>
</body>
</html>
);وسم script الأخير يحمّل src/client.tsx، الذي يستدعي initClient() لترطيب أي مكوّنات عميل في الصفحة. لو حذفته، سيظلّ تطبيقك يُصيَّر — لكنه يصبح ببساطة HTML ساكنًا بالكامل دون تفاعلية. وهذه قدرة مفيدة على التفكير في سلوك التطبيق.
// src/client.tsx
import { initClient } from "rwsdk/client";
initClient();الخطوة 3: اكتب أول مكوّن خادم
استبدل src/app/pages/Home.tsx بمكوّن خادم. ولأنه يعمل على الخادم، فبإمكانه قراءة كل ما يستطيع الـ Worker قراءته — بلا useEffect، وبلا شلّالات جلب، وبلا حالة تحميل.
يمرّر RedwoodSDK إلى كل صفحة كائن RequestInfo ضمن الخصائص، ويحتوي على request وparams وctx وresponse:
// src/app/pages/Home.tsx
import type { RequestInfo } from "rwsdk/worker";
export function Home({ request }: RequestInfo) {
const url = new URL(request.url);
return (
<main>
<h1>Snippets</h1>
<p>Serving from {url.hostname}</p>
<a href="/new">Create a snippet</a>
</main>
);
}مهم: داخل مكوّن الخادم، خذ
requestInfoمن الخصائص. لا تستدعِgetRequestInfo()هناك — فتلك الدالة المساعدة موجودة من أجل دوال الخادم التي لا تتلقّى خصائص. استدعاؤها داخل مكوّن خادم يرمي خطأً وقت التشغيل. وهذا أشيع خطأ يقع فيه المبتدئون.
تعمل المقاطع الديناميكية كما تتوقّع، وتظهر في params:
// src/worker.tsx
route("/s/:key", SnippetPage),// src/app/pages/SnippetPage.tsx
import type { RequestInfo } from "rwsdk/worker";
export function SnippetPage({ params }: RequestInfo) {
return <h1>Snippet: {params.key}</h1>;
}الخطوة 4: أضف قاعدة بيانات باستخدام D1
قاعدة Cloudflare D1 هي قاعدة SQLite تعمل على الحافة. أنشئ واحدة:
npx wrangler d1 create snippets-dbيطبع Wrangler قيمة database_id. أضف الربط إلى wrangler.jsonc:
{
"name": "snippets",
"main": "src/worker.tsx",
"compatibility_date": "2026-01-01",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "snippets-db",
"database_id": "paste-the-id-wrangler-printed"
}
]
}أنشئ ملف ترحيل في migrations/0001_create_snippets.sql:
CREATE TABLE IF NOT EXISTS snippets (
key TEXT PRIMARY KEY,
title TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'text',
body TEXT NOT NULL,
created_at INTEGER NOT NULL
);طبّقه على القاعدة المحلية، ثم على الإنتاج:
# القاعدة المحلية (Miniflare) التي يستخدمها pnpm dev
npx wrangler d1 migrations apply snippets-db --local
# قاعدة D1 البعيدة الحقيقية
npx wrangler d1 migrations apply snippets-db --remoteالآن اكتب طبقة بيانات رفيعة. يأتي الكائن env من cloudflare:workers وهو مكتمل الأنواع:
// src/db.ts
import { env } from "cloudflare:workers";
export type Snippet = {
key: string;
title: string;
language: string;
body: string;
created_at: number;
};
export async function listSnippets(): Promise<Snippet[]> {
const { results } = await env.DB
.prepare("SELECT * FROM snippets ORDER BY created_at DESC LIMIT 50")
.all<Snippet>();
return results;
}
export async function getSnippet(key: string): Promise<Snippet | null> {
return await env.DB
.prepare("SELECT * FROM snippets WHERE key = ?")
.bind(key)
.first<Snippet>();
}
export async function createSnippet(input: Omit<Snippet, "created_at">) {
await env.DB
.prepare(
"INSERT INTO snippets (key, title, language, body, created_at) VALUES (?, ?, ?, ?, ?)"
)
.bind(input.key, input.title, input.language, input.body, Date.now())
.run();
}لاحظ استدعاءات .bind(). العبارات المُهيّأة في D1 مُمَعلَمة، وهذا ما يبقي حقن SQL خارج الطاولة — لا تدمج مدخلات المستخدم داخل نصّ الاستعلام أبدًا.
اربط الاستعلامات بصفحاتك. هنا تثبت مكوّنات الخادم جدواها: يحدث استدعاء قاعدة البيانات أثناء التصيير، على الجهاز نفسه، دون أي رحلة ذهاب وإياب إلى العميل.
// src/app/pages/Home.tsx
import { listSnippets } from "@/db";
export async function Home() {
const snippets = await listSnippets();
return (
<main>
<h1>Snippets</h1>
<a href="/new">Create a snippet</a>
<ul>
{snippets.map((s) => (
<li key={s.key}>
<a href={`/s/${s.key}`}>{s.title}</a>
<span> — {s.language}</span>
</li>
))}
</ul>
</main>
);
}// src/app/pages/SnippetPage.tsx
import type { RequestInfo } from "rwsdk/worker";
import { getSnippet } from "@/db";
export async function SnippetPage({ params }: RequestInfo) {
const snippet = await getSnippet(params.key);
if (!snippet) {
return new Response("Not found", { status: 404 });
}
return (
<main>
<h1>{snippet.title}</h1>
<pre>
<code>{snippet.body}</code>
</pre>
</main>
);
}عبارة return new Response(...) داخل مكوّن صفحة ليست حيلة — إذ يجوز لمعالج المسار أن يعيد عنصر React أو كائن Response، ويتصرّف RedwoodSDK بالشكل الصحيح مع كليهما. رمز حالة 404 حقيقي، من داخل مكوّن، دون أي واجهة برمجية خاصّة.
الخطوة 5: مكوّنات العميل ودوال الخادم
لا تستطيع مكوّنات الخادم استخدام الحالة أو معالجات الأحداث. لنموذج "إنشاء مقتطف" تحتاج إلى جزيرة عميل، مُعلَّمة بالتوجيه 'use client':
// src/app/components/SnippetForm.tsx
"use client";
import { useState, useTransition } from "react";
import { saveSnippet } from "@/app/actions/saveSnippet";
export function SnippetForm() {
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function onSubmit(formData: FormData) {
startTransition(async () => {
const result = await saveSnippet(formData);
if (result.ok) {
window.location.href = `/s/${result.key}`;
} else {
setError(result.error);
}
});
}
return (
<form action={onSubmit}>
<input name="title" placeholder="Title" required />
<input name="language" placeholder="typescript" defaultValue="text" />
<textarea name="body" placeholder="Paste your code" required rows={12} />
<button type="submit" disabled={isPending}>
{isPending ? "Saving…" : "Save snippet"}
</button>
{error && <p role="alert">{error}</p>}
</form>
);
}يستدعي النموذج الدالة saveSnippet الموجودة في ملف مُعلَّم بـ 'use server'. هذه الدالة لا تُرسَل إلى المتصفّح أبدًا. تستبدل React الاستيراد بكعب استدعاء بعيد، ويؤدّي استدعاؤها إلى إرسال طلب POST إلى الـ Worker الخاص بك:
// src/app/actions/saveSnippet.ts
"use server";
import { createSnippet } from "@/db";
export async function saveSnippet(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
const body = String(formData.get("body") ?? "");
const language = String(formData.get("language") ?? "text").trim();
if (title.length === 0) {
return { ok: false as const, error: "Title is required." };
}
if (body.length > 100_000) {
return { ok: false as const, error: "Snippet is too large." };
}
const key = crypto.randomUUID().slice(0, 8);
await createSnippet({ key, title, language, body });
return { ok: true as const, key };
}نقطتان تستحقّان التشديد. أولًا، تحقّق من صحّة المدخلات داخل دالة الخادم، لا في النموذج وحده. فحدّ 'use server' هو نقطة نهاية HTTP عامّة؛ بوسع أي أحد استدعاؤها بأي حمولة. التحقّق في جهة العميل راحة للمستخدم، ولا يصلح ضابطًا أمنيًّا أبدًا.
ثانيًا، إن احتاجت دالة الخادم إلى سياق الطلب — المستخدم الحالي، أو كوكي، أو عنوان URL — فإنها تستورد requestInfo بدلًا من تلقّيه ضمن الخصائص:
"use server";
import { requestInfo } from "rwsdk/worker";
export async function whoAmI() {
const { ctx, request } = requestInfo;
return ctx.user?.username ?? "anonymous";
}أخيرًا، صيّر الجزيرة من صفحة مكوّن خادم:
// src/app/pages/NewSnippet.tsx
import { SnippetForm } from "@/app/components/SnippetForm";
export function NewSnippet() {
return (
<main>
<h1>New snippet</h1>
<SnippetForm />
</main>
);
}يُرسَل إلى المتصفّح مكوّن SnippetForm واعتمادياته فقط. أما قوقعة الصفحة والتخطيط وشيفرة قاعدة بياناتك فتبقى على الخادم.
الخطوة 6: الوسيطات وctx والمقاطِعات
لا يملك RedwoodSDK ملفّ وسيطات منفصلًا. الوسيط مجرّد دالة تضعها مبكّرًا في مصفوفة defineApp. يتلقّى معلومات الطلب نفسها، وبوسعه فعل أحد أمرين: تعديل ctx وعدم إعادة شيء (فيتابع الطلب مساره)، أو إعادة Response (فيقطع كل ما بعده).
// src/worker.tsx
import { defineApp, ErrorResponse } from "rwsdk/worker";
import { route, render } from "rwsdk/router";
export default defineApp([
// يعمل مع كل طلب، قبل مطابقة المسارات
async function sessionMiddleware({ request, ctx }) {
const session = await sessionStore.load(request);
ctx.session = session ?? { userId: null };
},
async function userMiddleware({ ctx }) {
if (ctx.session.userId) {
ctx.user = await getUser(ctx.session.userId);
}
},
render(Document, [
route("/", Home),
route("/new", NewSnippet),
route("/s/:key", SnippetPage),
// مقاطِعات على مستوى المسار: مصفوفة معالجات تعمل بالترتيب
route("/admin", [
function requireUser({ ctx }) {
if (!ctx.user) {
throw new ErrorResponse(401, "Unauthorized");
}
},
AdminPage,
]),
]),
]);يُظهر المسار /admin نمط المقاطِع في RedwoodSDK. فبدلًا من معالج واحد، مرّر مصفوفة. تعمل كل دالة بالترتيب؛ وأول واحدة تعيد Response (أو ترمي ErrorResponse) توقف السلسلة. لن يعمل AdminPage إلا لمستخدم موثَّق، وبوسعه قراءة ctx.user بثقة.
هذا يتركّب جيدًا. المقاطِع دالة عادية، لذا يمكن تصدير requireUser من وحدة مشتركة وإعادة استخدامها عبر عشرات المسارات دون أي خطوة تسجيل في الإطار.
ولتحديد أنواع ctx، وسّع واجهة AppContext الخاصة بالإطار:
// src/types.d.ts
declare module "rwsdk/worker" {
interface AppContext {
session: { userId: string | null };
user?: { id: string; username: string };
}
}الخطوة 7: جلسات مدعومة بـ Durable Object
لا تستطيع الكوكيز وحدها حفظ حالة الجلسة على الخادم، وليس لدى Workers ذاكرة بين الطلبات. جواب RedwoodSDK هو Durable Object — كائن أحادي الخيط، متّسق بقوّة، له تخزينه الخاص، ويُعنوَن بمعرّف.
عرّف الكائن:
// src/sessions/UserSession.ts
interface SessionData {
userId: string | null;
}
export class UserSession implements DurableObject {
private storage: DurableObjectStorage;
private session: SessionData | undefined;
constructor(state: DurableObjectState) {
this.storage = state.storage;
}
async getSession() {
if (!this.session) {
this.session =
(await this.storage.get<SessionData>("session")) ?? { userId: null };
}
return { value: this.session };
}
async saveSession(data: Partial<SessionData>) {
this.session = { userId: data.userId ?? null };
await this.storage.put("session", this.session);
return this.session;
}
async revokeSession() {
await this.storage.delete("session");
this.session = undefined;
}
}اربطه في wrangler.jsonc. تخبر كتلة migrations منصّةَ Cloudflare بأن هذا الصنف يحتاج تخزينًا مدعومًا بـ SQLite:
{
"durable_objects": {
"bindings": [
{ "name": "USER_SESSION_DO", "class_name": "UserSession" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["UserSession"] }
]
}ثم أنشئ مخزن الجلسات في الـ Worker وأعد تصدير الصنف كي يعثر عليه Wrangler:
// src/worker.tsx
import { defineDurableSession } from "rwsdk/auth";
import { env } from "cloudflare:workers";
import { UserSession } from "./sessions/UserSession";
export const sessionStore = defineDurableSession({
sessionDurableObject: env.USER_SESSION_DO,
});
export { UserSession };تقرأ sessionStore.load(request) كوكي الجلسة الموقَّع، وتوجّه الطلب إلى نسخة Durable Object الصحيحة، وتعيد بياناتها. هذا هو sessionMiddleware من الخطوة السابقة، موصولًا الآن بالكامل.
وللتوثيق الحقيقي، يوفّر RedwoodSDK إضافة passkey تبني WebAuthn فوق مخزن الجلسات نفسه. ولأنها مبنيّة من الأوّليات ذاتها التي رأيتها للتو، يمكنك قراءة شيفرتها وفهمها من طرف إلى طرف.
الخطوة 8: النشر على الحافة
النشر أمر واحد:
pnpm releaseيبني هذا الأمر حزم العميل والخادم، ويرفع الأصول الساكنة، ويطبّق إعدادات الـ Worker، وينشر على نطاقك الفرعي على workers.dev (أو نطاقك المخصّص إن كان مضبوطًا). تذكّر أن تشغّل الترحيلات البعيدة أولًا — فقاعدة D1 إنتاجية جديدة لا تحوي أي جداول:
npx wrangler d1 migrations apply snippets-db --remote
pnpm releaseولبيئة تجريبية، عرّف كتلة env.staging في wrangler.jsonc واخترها عبر متغيّر بيئة:
CLOUDFLARE_ENV=staging pnpm releaseاختبار ما بنيته
تحقّق من التطبيق من طرف إلى طرف قبل أن تعدّه منجزًا:
- شغّل
pnpm devوتأكّد أن الصفحة الرئيسية تعرض قائمة فارغة دون أخطاء. - زُر
/new، وأرسل مقتطفًا، وتأكّد أن المتصفّح يعيد توجيهك إلى/s/:keyمع محتواك. - تأكّد أن دالة الخادم نقطة نهاية حقيقية. افتح تبويب الشبكة في متصفّحك أثناء الإرسال. ينبغي أن ترى طلب POST إلى الصفحة الحالية — ذلك هو استدعاء RPC. دوال الخادم نقاط نهاية HTTP، ورؤية ذلك الطلب تجعل الحدّ ملموسًا.
- افحص ما وصل إلى العميل. اعرض مصدر الصفحة
/s/:key. يجب ألّا تظهر شيفرة قاعدة بياناتك ولاsaveSnippetفي أي مكان من حزمة JavaScript. - اختبر الحارس. زُر
/adminبلا جلسة. ينبغي أن تحصل على 401، لا على صفحة مُصيَّرة. - تحقّق من الديمومة. أعد تشغيل
pnpm devوتأكّد من بقاء مقتطفاتك، لأنها تعيش في ملف D1 المحلي لا في الذاكرة.
استعلام سريع يؤكّد أن البيانات استقرّت حيث تظنّ:
npx wrangler d1 execute snippets-db --local \
--command "SELECT key, title FROM snippets"حلّ المشكلات الشائعة
getRequestInfo() ترمي خطأً داخل مكوّن صفحة. مكوّنات الخادم تتلقّى requestInfo ضمن الخصائص. فكّك request وparams وctx من خصائص المكوّن بدلًا من ذلك. واحفظ getRequestInfo() واستيراد requestInfo لدوال 'use server'.
env.DB is undefined. يجب أن يطابق اسم الربط في wrangler.jsonc الخاصية التي تصل إليها تمامًا. ربط باسم DB يعطيك env.DB لا env.db أبدًا. أعد تشغيل خادم التطوير بعد تعديل wrangler.jsonc، لأن الروابط تُقرأ عند الإقلاع.
no such table: snippets أثناء التطوير. طبّقت الترحيلات على البعيد لا على المحلي. أعد تشغيل أمر الترحيل مع الراية --local.
مكوّن عميل يفشل برسالة "cannot read state of undefined". يجب أن يكون التوجيه 'use client' أول سطر في الملف، قبل أي استيراد. توجيه في غير موضعه يحوّل الملف بصمت إلى مكوّن خادم.
فشل الترطيب، أو انعدام التفاعلية. تأكّد أن Document لا يزال يحتوي وسم السكربت الذي يستورد /src/client.tsx، وأن src/client.tsx يستدعي initClient().
صنف Durable Object غير موجود عند النشر. يجب تصدير الصنف من نقطة دخول الـ Worker، ويحتاج مدخلًا في مصفوفة migrations داخل wrangler.jsonc. كلاهما مطلوب.
الخطوات التالية
أصبح لديك الآن تطبيق React متكامل يعمل بالكامل على شبكة Cloudflare، مع قاعدة بيانات وجلسات ودوال خادم وحرّاس مسارات. إليك بعض الاتجاهات الجديرة بالاستكشاف:
- أضف الزمن الحقيقي. يعرض RedwoodSDK طبقة زمن حقيقي مبنيّة على Durable Objects وWebSockets، فيظهر المقتطف الجديد في كل تبويب مفتوح دون استقصاء دوري.
- ابثّ الصفحات الطويلة. لأن الاستجابات تُبثّ، يمكنك تغليف قسم بطيء بـ
Suspenseمن React وترك القوقعة تُرسَم فورًا. - أضف طبقة ORM. يعمل كلٌّ من Prisma وDrizzle فوق D1 حين تريد ترحيلات وأنواعًا مولَّدة من مخطَّط بدل SQL المكتوب يدويًّا.
- خزّن الملفات في R2. أضف ربط R2 ودع المستخدمين يرفقون صورًا بالمقتطفات. النمط مطابق لنمط D1 تمامًا: اربطه، وصِل إليه عبر
env، واستدعِه من دالة خادم.
وإن أردت مقارنة المقاربات، فدرسانا حول Waku وOpenNext على Cloudflare Workers يغطّيان رؤيتين مختلفتين جدًّا لنموذج React نفسه. ودليل Cloudflare Workers وHono رفيق جيّد إن أردت بناء واجهة برمجية صرفة إلى جانب هذا التطبيق.
الخلاصة
حجّة RedwoodSDK المركزية أن على الإطار أن يجعل المنصّة مقروءة بدل أن يخفيها. وكل ما في هذا الدرس تفرّع عن ذلك: المسارات دوال، والوسيطات دوال، ومستند HTML ملكك، وقاعدة البيانات يُوصَل إليها عبر ربط env نفسه الذي يستخدمه Worker مجرَّد.
المقايضة صريحة. تتنازل عن قابلية النقل وعن منظومة إضافات كبيرة. وتحصل في المقابل على قاعدة شيفرة لا يحدث فيها شيء مهمّ خارج الشاشة — قاعدة يمكنك فيها تتبّع طلب من defineApp عبر الوسيطات، إلى مكوّن خادم، خارجًا عبر دالة خادم، وصولًا إلى عبارة D1 مُهيّأة، وأنت لا تقرأ طوال الطريق سوى شيفرتك أنت.
بالنسبة إلى الفرق الملتزمة أصلًا بـ Cloudflare، تساوي هذه الشفافية الكثير. ابدأ بتطبيق المقتطفات الذي بنيته للتو، وأضف الزمن الحقيقي أو R2 عند الحاجة، ودع الإطار يبتعد عن طريقك.