Leptos 0.8.22SSR على Axum، وعميل TypeScript، ومن غير WASM

المتجر ده متبني إزاي

‏Leptos بيرسم كل صفحة على السيرفر. وعميل TypeScript صغير بيغيّر الحالة في المتصفح ومابيطلبش من السيرفر غير البيانات اللي مش عنده. كل كارت تحت بيقول الجزء ده بيعمل إيه وليه، وبيشغّله قدامك، وبيوديك للمكان اللي المتجر بيستخدمه فيه، وبيعرض الكود.

  • 16كارت، وفي كل واحد ديمو حي
  • 0بايت WebAssembly
  • 0.9 KBJavaScript من غير ضغط، مصغّر
  • سوقالمحل، من الـ Host
طلب واحد من أوله لآخره
  1. 1المتصفحبيطلب ‎/showcase‎ ومعاه الـ Host والكوكيز.GET /showcase
  2. 2Axumبيحدد المحل من الـ Host، وبيقدّم ‎/js‎ و ‎/media‎ وأدوات ‎/__prefs‎ و ‎/__sim‎، وبيضغط.tower-http · moka
  3. 3Leptos SSRبيرسم الـ route بالترتيب في مستند واحد كامل؛ وكود الحالة والهيدرز بيخرجوا الأول.SsrMode::InOrder
  4. 4بيانات الصفحة‏#app-config و #app-strings: المحل واللغة والـ endpoints وكل نصوص الواجهة اللي العميل محتاجها.application/json
  5. 5TypeScriptملف واحد بيبدأ: يقرا الصفحة، ويظبط الـ stores، ويركّب الـ features، ويشغّل الراوتر./js/app-….js

Leptos على السيرفر

كل صفحة كومبوننت Leptos بيترسم على السيرفر في مستند HTML واحد كامل. مفيش Rust بيشتغل في المتصفح.

01

SSR بالترتيب في كل صفحة

Leptos على السيرفر

كل route بيترسم بـ SsrMode::InOrder. ‏Leptos بيرسم الصفحة من فوق لتحت وبيبعتها بنفس الترتيب، وبيستنى عند كل <Suspense> لحد ما بياناته توصل، فكل بايت طالع من السيرفر HTML نهائي. مفيش سكريبت محتاج يحط حاجة في مكانها.

ليه ده مهم هناالصفحات لازم تشتغل من غير JavaScript، والراوتر بتاع TypeScript بيبدّل صفحات كاملة جاية من السيرفر: الاتنين محتاجين HTML كامل. وكود الحالة وهيدرز الكاش بيتحددوا قبل أول بايت، فالـ 404 بيبقى 404 بجد.

ديمو حي

الـ routeالرسم
/InOrder
/c/:slugInOrder
/searchInOrder
/p/:slugInOrder
/cartInOrder
/cart/editInOrder
/checkoutInOrder
/ordersInOrder
/orders/:idInOrder
/showcaseInOrder
/uiInOrder
/core-scratchInOrder
/showcase/streamOutOfOrder

متقري من crates/app/src/lib.rs وقت بناء السيرفر.

أول بايت من الصفحة دي
—
آخر بايت
—
الـ HTML، مضغوط ومفكوك
—
السكريبت اللازم عشان تشوف الصفحة
ولا حاجة

التوقيتات من المتصفح نفسه (Navigation Timing)، للمستند اللي التاب ده حمّله الأول.

الكود
  • crates/app/src/lib.rs
  • crates/app/src/components/shell.rs
crates/app/src/lib.rs:92اعرض الكود · 20
<Routes fallback=|| view! { <pages::NotFound/> }>
    // Every page renders in order: one complete document that works without JS
    // and that the client router can swap in (out-of-order chunks need scripts a
    // fetched document never runs). The out-of-order demo has its own route.
    <Route path=path!("") view=pages::HomePage ssr=SsrMode::InOrder/>
    <Route path=path!("c/:slug") view=pages::CategoryPage ssr=SsrMode::InOrder/>
    <Route path=path!("search") view=pages::SearchPage ssr=SsrMode::InOrder/>
    <Route path=path!("p/:slug") view=pages::ProductPage ssr=SsrMode::InOrder/>
    <Route path=path!("cart") view=pages::CartPage ssr=SsrMode::InOrder/>
    <Route path=path!("cart/edit") view=pages::cart::CartEditPage ssr=SsrMode::InOrder/>
    <Route path=path!("checkout") view=pages::CheckoutPage ssr=SsrMode::InOrder/>
    <Route path=path!("orders") view=pages::OrdersLookupPage ssr=SsrMode::InOrder/>
    <Route path=path!("orders/:id") view=pages::OrderPage ssr=SsrMode::InOrder/>
    <Route path=path!("showcase") view=pages::ShowcasePage ssr=SsrMode::InOrder/>
    <Route path=path!("ui") view=pages::UiKitPage ssr=SsrMode::InOrder/>
    // The core agent's scratch route (dev checks of core behaviour and components).
    <Route path=path!("core-scratch") view=pages::core_scratch::CoreScratchPage ssr=SsrMode::InOrder/>
    // Showcase agent (agreed with the lead): the one out-of-order route, the demo.
    <Route path=path!("showcase/stream") view=pages::showcase::StreamDemoPage ssr=SsrMode::OutOfOrder/>
</Routes>
crates/app/src/components/shell.rs:178اعرض الكود · 6
let config = serde_json::to_string(&config)
    .unwrap_or_default()
    .replace('<', "\\u003c");
view! {
    <script type="application/json" id="app-config" inner_html=config></script>
    <script type="application/json" id="app-strings" inner_html=crate::strings::client_json(rq.prefs.lang, &rq.path_and_query)></script>
02

البث بأي ترتيب، في route لوحده

Leptos على السيرفر

‏Leptos يقدر كمان يبعت إطار الصفحة الأول، وبعدين كل <Suspense> أول ما بياناته تخلص. كل قسم بيوصل كـ <template> ومعاه سكريبت صغير بيحطه في مكانه. صفحة الديمو ‎/showcase/stream‎ هي الوحيدة هنا اللي بتتبعت بالطريقة دي.

ليه ده مهم هنابيدّي أسرع أول رسمة، بس الأقسام مابتتحطش في مكانها غير لو الـ JavaScript شغال، والراوتر اللي بيجيب الصفحة كـ HTML مش هياخدها غير لما أبطأ قسم يخلص. عشان كده لينكات الديمو عليها data-router="off": التحميل الكامل للصفحة هو الطريقة الوحيدة اللي تتفرج بيها على البث.

ديمو حي

التأخير التجريبي مقفول: البلوكات كلها هتوصل مع بعض. اختار تأخير الأول.

افتح ديمو البثوالعروض فاشلة

الاتنين بيفتحوا تحميل كامل للصفحة (data-router="off"). راوتر العميل كان هيجيب الصفحة كـ HTML وياخدها بعد أبطأ بلوك، فمش هتشوف أي بث.

مستخدم في

صفحة الديمو بس هي اللي بتتبعت بأي ترتيب؛ كل صفحات المتجر بتترسم بالترتيب (الكارت اللي فوق).

الكود
  • crates/app/src/pages/showcase/stream.rs
  • crates/app/src/lib.rs
crates/app/src/lib.rs:110اعرض الكود · 1
<Route path=path!("showcase/stream") view=pages::showcase::StreamDemoPage ssr=SsrMode::OutOfOrder/>
crates/app/src/pages/showcase/stream.rs:202اعرض الكود · 26
let data = Resource::new(move || (), move |_| api::home_rail(rail, 3));
let name = tr(lang, label.0, label.1);
// The Suspend below may run more than once; the block takes its arrival rank once.
let seen: Arc<std::sync::OnceLock<(u64, u32)>> = Arc::new(std::sync::OnceLock::new());
view! {
    <section class="sc-sblock" aria-label=name data-stream-block="">
        <header class="sc-sblock__head">
            <strong>{name}</strong>
            <code class="sc-tag mono" dir="ltr">{format!("home_rail({rail:?})")}</code>
        </header>
        <Suspense fallback=|| view! { <BlockSkeleton/> }>
            <ErrorBoundary fallback=|errors| view! { <SectionError errors compact=true/> }>
                {
                    let clock = clock.clone();
                    move || {
                        let clock = clock.clone();
                        let seen = seen.clone();
                        Suspend::new(async move {
                            let r = data.await;
                            let (ms, rank) = *seen.get_or_init(|| clock.arrived());
                            r.map(|cards| view! {
                                <p class="sc-sblock__meta" data-streamed="">
                                    <span class="sc-rank mono">{format!("#{}", format::count(rank, num))}</span>
                                    <span class="mono">{match lang {
                                        Lang::En => format!("finished after {} ms", format::int(ms as i64, num)),
                                        Lang::Ar => format!("خلص بعد {} مللي ثانية", format::int(ms as i64, num)),
03

دوال السيرفر هي الـ API

Leptos على السيرفر

‏#[server(endpoint = "cache/stats", input = GetUrl)]‎ بيحوّل دالة Rust ‏async لـ endpoint ليه URL ثابت. كومبوننتس السيرفر بتناديها زي أي دالة. وعميل TypeScript بيعمل fetch للـ URL ويرجعله JSON، والفلوس متنسقة جاهزة.

ليه ده مهم هنادالة Rust واحدة بتخدم قراءات السيرفر نفسه وطلبات العميل، فمفيش طبقة REST تانية لازم تفضل ماشية معاها. والأسماء الثابتة بتخلّي الـ URLs زي ما هي من build للتاني (الأسماء الافتراضية فيها hash)، فالعميل والـ CDN يقدروا يعتمدوا عليها.

ديمو حي

افتح الـ JSON

الحالة
—
الوقت
—
Cache-Control
—
{ … }

الـ endpoints اللي عميل الصفحة دي يقدر يكلمها، من ‎#app-config‎:

  • GET /api/cache/stats
  • POST /api/cache/bump
الكود
  • crates/app/src/api/mod.rs
  • client/src/core/net.ts
crates/app/src/api/mod.rs:94اعرض الكود · 14
#[server(endpoint = "cache/stats", input = leptos::server_fn::codec::GetUrl)]
pub async fn cache_stats() -> Result<CacheInfo, ServerFnError> {
    let rq = crate::request::init();
    crate::server::cache::private();
    let st = state();
    let s = st.reads().stats();
    Ok(CacheInfo {
        entries: s.entries,
        hits: s.hits,
        misses: s.misses,
        generation: st.reads().generation(&rq.tenant.id),
        base_latency_ms: crate::server::sim::base_ms(),
    })
}
client/src/core/net.ts:61اعرض الكود · 8
export async function getJSON<T>(url: string, opts: RequestOpts = {}): Promise<T> {
  const res = await request(url, { ...opts, init: { ...opts.init, headers: { Accept: "application/json" } } });
  try {
    return (await res.json()) as T;
  } catch {
    throw new NetError("parse");
  }
}
04

حدود للأخطاء، واحد لكل قسم

Leptos على السيرفر

كل قسم بيقرا بيانات ليه <ErrorBoundary> خاص بيه. لو القراءة فشلت، القسم ده بس بيقول إيه اللي فشل ويديك «إعادة المحاولة»، وباقي الصفحة بيترسم عادي. ولو الفشل معروف قبل أول بايت، بتطلع صفحة خطأ كاملة بـ 500 أو 503 حقيقي.

ليه ده مهم هنالو خدمة الترشيحات بطيئة أو واقعة، ماينفعش توقّع صفحة المنتج معاها. و ‎?fail=<widget>‎ بيبوّظ قراءة واحدة بالقصد، عشان نقدر نجرّب كل حالة خطأ.

ديمو حي

عروض النهاردهhome_rail(Deals)
الأكثر مبيعًاhome_rail(Bestsellers)

تعذّر تحميل الأكثر مبيعًا. باقي الصفحة يعمل بشكل طبيعي.

إعادة المحاولةإلغاء الأعطال التجريبية
الصفحة كلها404500503

كل بلوك بيقرا جوه <Suspense> و <ErrorBoundary> خاصين بيه، ومترسومين بالترتيب. لو واحد فشل، التاني بيظهر عادي، وباقي الصفحة كمان.

الكود
  • crates/app/src/pages/errors.rs
  • crates/app/src/server/sim.rs
crates/app/src/pages/errors.rs:226اعرض الكود · 6
pub fn SectionError(
    #[prop(optional)] errors: Option<ArcRwSignal<Errors>>,
    #[prop(optional, into)] what: Option<String>,
    #[prop(optional, into)] retry_href: Option<String>,
    #[prop(optional)] compact: bool,
) -> impl IntoView {
crates/app/src/server/sim.rs:103اعرض الكود · 10
pub fn check_fail(w: Widget) -> Result<(), ServerFnError> {
    let wanted = query_param("fail").or_else(|| cookie("fail")).unwrap_or_default();
    if wanted.split([',', '+', ' ']).any(|x| x == w.code() || x == "all") {
        return Err(ServerFnError::ServerError(format!(
            "simulated failure in '{}' (remove ?fail= to recover)",
            w.code()
        )));
    }
    Ok(())
}
05

كاش القراءة (moka)

Leptos على السيرفر

قراءات الكتالوج كلها بتعدّي على كاش moka واحد، مفتاحه tenant:generation:kind:key. العناصر بتنتهي بعد الـ TTL بتاعها، ولو زوّدنا الـ generation بتاع محل، كل عناصره بتتشال مرة واحدة من غير ما نلف عليها.

ليه ده مهم هناالليستات والأقسام ونتايج البحث بتتحسب مرة واحدة وتتقدّم من الميموري لكل زائر بيطلب نفس الحاجة. وتعديل الكتالوج مجرد زيادة عداد، ومعاها purge للـ CDN بالـ Cache-Tag في الإنتاج.

ديمو حي

العناصر
32
مرات لقاها
2,528
مرات ملقاهاش
4,822
نسبة اللقيان
34%
الـ generation بتاع المحل ده
0

الزيادة بتشيل كل القرايات المتخزنة للمحل ده: أول صفحة تفتحها بعدها مش هتلاقي، وبعدين هتلاقي تاني.

الكود
  • crates/app/src/server/cache.rs
  • crates/app/src/api/mod.rs
crates/app/src/server/cache.rs:133اعرض الكود · 9
let full = format!("{tenant}:{}:{kind}:{key}", self.generation(tenant));
let mut missed = false;
let value = self
    .cache
    .get_with(full, async {
        missed = true;
        let miss_ms: u64 = std::env::var("CACHE_MISS_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(0);
        if miss_ms > 0 {
            tokio::time::sleep(Duration::from_millis(miss_ms)).await;
crates/app/src/server/cache.rs:120اعرض الكود · 3
pub fn bump(&self, tenant: &str) {
    self.generations.insert(tenant.to_owned(), self.generation(tenant) + 1);
}
06

المحل من الـ Host

Leptos على السيرفر

سيرفر واحد بيخدم المحلين. المحل بيتحدد من أول جزء في الـ Host ‏(nile.…‏ و souq.…). وبيتحدد قبل الرسم، فاللوجو والاسم واللغة الافتراضية بيبقوا في أول بايت.

ليه ده مهم هناإضافة محل جديد بيانات، مش deployment. والـ hosts اللي من غير اسم محل، زي IP على الموبايل، بترجع لكوكي المحل.

ديمو حي

Host
souq.leptos-preview.duckdns.org
اتحدد من
أول جزء في الـ Host (طريقة الإنتاج)
المحل
souq · Souq / سوق
اللغة الافتراضية
العربية
الكتالوج
13 قسم · 192 منتجًا
التوصيل
⁦25.00⁩ ج.م · مجاني فوق ⁦600.00⁩ ج.م · سريع في 30 دقيقة
أكواد الطلبات
SOQ-48213
بادئة الـ Cache-Tag
tsouq

الصفحة دي في نايل‏subdomain تاني يعني origin تاني: تحميل كامل للصفحة، وكوكيز خاصة بيه.

الكود
  • crates/app/src/tenant.rs
  • crates/app/src/request.rs
crates/app/src/tenant.rs:59اعرض الكود · 7
pub fn tenant_from_host(host: &str) -> Option<&'static str> {
    let hostname = strip_port(host.trim());
    let mut labels = hostname.split('.');
    let first = labels.next()?.to_ascii_lowercase();
    labels.next()?;
    TENANT_IDS.iter().copied().find(|id| *id == first)
}
crates/app/src/request.rs:85اعرض الكود · 9
let (tenant_id, via) = match tenant_from_host(&host) {
    Some(id) => (id, TenantVia::Subdomain),
    None => match cookie(COOKIE_TENANT)
        .and_then(|c| TENANT_IDS.iter().copied().find(|id| *id == c))
    {
        Some(id) => (id, TenantVia::Cookie),
        None => (DEFAULT_TENANT, TenantVia::Default),
    },
};
07

الـ head والـ meta

Leptos على السيرفر

‏<Title> و <Meta> و <Link> من leptos_meta بيتكتبوا في ساعتها من بيانات في الميموري. العنوان والوصف والـ canonical والـ OpenGraph والـ JSON-LD كلهم بيبقوا في الـ <head> من أول chunk.

ليه ده مهم هنامحركات البحث ومعاينات اللينكات بتقرا أول رد ومابتشغّلش سكريبتات. ومع الـ SSR بالترتيب ومن غير head بيستنى بيانات، بيشوفوا بالظبط اللي الزبون بيشوفه.

ديمو حي

<title>
المتجر ده متبني إزاي
robots
noindex

متقري دلوقتي من الـ <head> بتاع المستند ده. وبعد التنقل جوه الموقع بيعرض الـ head بتاع الصفحة الجديدة، اللي الراوتر بيجيبه معاها.

الكود
  • crates/app/src/pages/product/head.rs
  • crates/app/src/pages/home.rs
crates/app/src/pages/product/head.rs:221اعرض الكود · 14
<Title text=h.title.clone()/>
<Meta name="description" content=h.description.clone()/>
<Link rel="canonical" href=h.canonical.clone()/>
{preload}
<Meta property="og:type" content="product"/>
<Meta property="og:site_name" content=h.site_name.clone()/>
<Meta property="og:title" content=h.title.clone()/>
<Meta property="og:description" content=h.description.clone()/>
<Meta property="og:url" content=h.canonical.clone()/>
<Meta property="og:locale" content=h.locale/>
<Meta property="og:locale:alternate" content=h.alt_locale/>
{og_image}
<Meta property="product:price:amount" content=h.price.clone()/>
<Meta property="product:price:currency" content="EGP"/>
08

تأخير وأعطال تجريبية

Leptos على السيرفر

‏‎/__sim?ms=350‎ بيحط كوكي ليك انت بس. بعدها قراءة كل widget بتستنى مضاعف من الرقم ده: العروض 2.6×، والمنتجات المشابهة 1.8×، والبحث 0.25×. و ‎?fail=<widget>‎ بيبوّظ قراءة واحدة في طلب واحد، و ‎/__sim?fail=deals‎ بيبوّظها طول الجلسة.

ليه ده مهم هناعلى localhost كل حاجة بترد في ساعتها، وده بيخبّي حالات التحميل والموبايلات البطيئة والأعطال الجزئية. هو مقفول افتراضيًا، وبيه اتجرّبت كل حالة تحميل وتأخير وخطأ هنا.

ديمو حي

التأخير الأساسي

التأخير التجريبي مقفول: البلوكات كلها هتوصل مع بعض. اختار تأخير الأول.

امسح كل الأعطال

العطل بيخلّي قراية الـ widget ده تفشل في كل الصفحات لحد ما تمسحه. جرّب «deals» وافتح الرئيسية.

الكود
  • crates/app/src/server/sim.rs
  • crates/server/src/main.rs
crates/app/src/server/sim.rs:86اعرض الكود · 7
pub fn base_ms() -> u64 {
    cookie("sim")
        .and_then(|v| v.parse().ok())
        .or_else(|| std::env::var("SIM_LATENCY_MS").ok().and_then(|v| v.parse().ok()))
        .unwrap_or(0)
        .min(10_000)
}
crates/app/src/server/sim.rs:95اعرض الكود · 6
pub async fn delay(w: Widget) {
    let ms = (base_ms() as f64 * w.factor()) as u64;
    if ms > 0 {
        tokio::time::sleep(Duration::from_millis(ms)).await;
    }
}

عميل TypeScript

ملف ES module واحد صغير، بيجمّعه esbuild. بيغيّر الحالة في ساعتها، ومابيكلمش السيرفر غير لما يحتاج بيانات مش عنده.

09

الراوتر وكاش الصفحات

عميل TypeScript

‏core/router.ts بيمسك اللينكات والفورمز اللي على نفس الموقع. بيجيب HTML الصفحة الجاية، ويبدّل أجزاء الصفحة، ويركّب الـ features في الجزء اللي وصل. الصفحات بتتخزن بـ TTL و stale-while-revalidate، والوقوف بالماوس (65 مللي ثانية) أو لمسة الصباع بيعملوا prefetch، و refresh()‎ بيتخطّى الكاش.

ليه ده مهم هناالتنقل بيبقى فوري من غير ما الصفحات تترسم في المتصفح. السيرفر لسه هو اللي بيرسم كل صفحة، والراوتر بس بينقل الـ HTML. ومن غير JavaScript نفس اللينكات بتبقى تحميل صفحة عادي.

ديمو حي

افتحها

routing.pending
false
routing.to
null
التنقلات في التاب ده
0

    اعمل prefetch وبعدين افتح: الصفحة بتطلع من كاش الصفحات. والوقوف على لينك 65 مللي ثانية أو حط صباعك عليه بيعمل نفس الحاجة.

    الكود
    • client/src/core/router.ts
    • client/src/core/cache.ts
    • client/src/main.ts
    client/src/core/router.ts:62اعرض الكود · 1
    const PAGE_CACHE = { ttl: 30_000, swr: 5 * 60_000 };
    client/src/core/router.ts:493اعرض الكود · 12
    // ── Prefetch: the gesture model ─────────────────────────────────────────────────────────────────
    // - a mouse or pen hover warms after HOVER_DELAY_MS (a pass-over warms nothing); one pending
    //   hover at a time — entering another target or leaving cancels it;
    // - keyboard focus (`:focus-visible`) waits the same; blur cancels; focus moved by code warms
    //   nothing;
    // - pointerdown warms at once (a phone's only signal; on desktop it buys the ~100 ms to the click)
    //   and is never throttled;
    // - the same href is warmed at most once per DEDUPE_MS; hovers and focus warm at most
    //   HOVER_BUDGET_PER_S targets a second; nothing is warmed on Save-Data or 2G.
    // Warming is the router's own GET into the page cache, so the click finds it there (or joins it
    // mid-flight). /__ links set preferences (state-changing GETs): never warmed.
    const HOVER_DELAY_MS = 65, DEDUPE_MS = 2000, HOVER_BUDGET_PER_S = 6;
    client/src/main.ts:39اعرض الكود · 11
    readPage();
    syncFromCookie();
    mountToaster();
    mountAll(document);
    startRouter();
    
    onNavigate(() => {
      readPage();
      syncFromCookie();
      mountAll(document);
    });
    10

    store واحد لكل حاجة

    عميل TypeScript

    ‏createStore(initial)‎ هو الأداة الوحيدة للحالة: قيمة ومعاها مشتركين. كل حاجة ليها store واحد بس، بيتعمل مرة واحدة في stores/*.ts: السلة، وطابور التنبيهات، وحالة الراوتر وهو بيحمّل. وأي feature محتاجة واحد منهم بتستورد نفس الـ store ده.

    ليه ده مهم هناعلامة السلة في الهيدر والدرج وصفحة السلة مستحيل يختلفوا طالما بيقروا من نفس الـ store. وكمان فيه طريق واحد لكل عملية: السلة مابتتغيرش غير من stores/cart.ts.

    ديمو حي

    cart
    —
    toasts
    —
    routing
    —

    كل سطر اشتراك حي. اعمل كام تنبيه: الطابور بيحتفظ بتلاتة، والأحدث قدام، والـ toaster بيرسمهم من نفس الـ store.

    الكود
    • client/src/core/store.ts
    • client/src/stores/toasts.ts
    • client/src/stores/cart.ts
    client/src/core/store.ts:12اعرض الكود · 19
    export function createStore<T>(initial: T): Store<T> {
      let value = initial;
      const subs = new Set<(v: T) => void>();
      const set = (next: T) => {
        if (Object.is(next, value)) return;
        value = next;
        for (const fn of [...subs]) fn(value);
      };
      return {
        get: () => value,
        set,
        update: (fn) => set(fn(value)),
        subscribe(fn) {
          subs.add(fn);
          fn(value);
          return () => subs.delete(fn);
        },
      };
    }
    client/src/stores/toasts.ts:31اعرض الكود · 6
    export function toast(msg: string, kind: ToastKind = "info", action?: Toast["action"]): number {
      const t: Toast = action ? { id: nextId++, msg, kind, action } : { id: nextId++, msg, kind };
      toasts.update((list) => [...list, t].slice(-MAX_VISIBLE));
      schedule(t);
      return t.id;
    }
    11

    السلة عايشة في كوكي

    عميل TypeScript

    المتصفح هو اللي ماسك السلة: سطور زي 2307:1:2 في كوكي cart، ومحدش بيكتبها غير stores/cart.ts. الإضافة والشيل وتغيير الكمية بيحصلوا عندك في ساعتها، من غير أي طلب. والسيرفر بيقرا نفس الكوكي بس لما يحتاج يسعّرها: في صفحة السلة، ولحساب الدرج، وعند الدفع.

    ليه ده مهم هنااللمسات عمرها ما بتستنى النت، والسلة بتفضل بعد الريلود وبتفضل متظبطة بين التابات، والفورمز اللي من غير JS بتغيّر نفس الكوكي بالظبط. والأسعار والمخزون والإجمالي لسه جايين من السيرفر ومتنسقين.

    ديمو حي

    كوكي cart
    —
    عدد القطع في السلة
    —
    طلبات عشان التغيير ده
    —
    حساب السيرفر
    —

    دي سلتك الحقيقية: علامة الهيدر والدرج ماشيين معاها. الإضافة والشيل مابيعملوش أي طلب؛ «اسأل السيرفر» بس هو اللي بيعمل، ومابيبعتش غير الكوكي.

    الكود
    • client/src/stores/cart.ts
    • crates/domain/src/cart.rs
    client/src/stores/cart.ts:96اعرض الكود · 8
    function commit(lines: CartLine[]): void {
      const value = serialize(lines);
      document.cookie = value
        ? `${COOKIE()}=${encodeURIComponent(value)}; Path=/; Max-Age=${MAX_AGE}; SameSite=Lax`
        : `${COOKIE()}=; Path=/; Max-Age=0; SameSite=Lax`;
      cart.set({ lines, count: count(lines) });
      channel?.postMessage("changed");
    }
    crates/domain/src/cart.rs:44اعرض الكود · 22
    pub fn parse(raw: &str) -> Vec<CartLine> {
        let mut lines: Vec<CartLine> = Vec::new();
        for part in raw.split('|') {
            let mut f = part.split(':');
            let (Some(p), Some(v), Some(q)) = (f.next(), f.next(), f.next()) else { continue };
            let (Ok(p), Ok(v), Ok(q)) = (p.parse::<u32>(), v.parse::<u32>(), q.parse::<u32>()) else { continue };
            if q == 0 {
                continue;
            }
            let mut addons: Vec<String> = f.next().map(|a| a.split('+').filter(|a| addon_ok(a)).map(str::to_owned).collect()).unwrap_or_default();
            addons.sort();
            addons.dedup();
            let line = CartLine { product: ProductId(p), variant: VariantId(v), qty: q.min(MAX_QTY), addons };
            let key = line.key();
            if let Some(l) = lines.iter_mut().find(|l| l.key() == key) {
                l.qty = (l.qty + line.qty).min(MAX_QTY);
            } else if lines.len() < MAX_LINES {
                lines.push(line);
            }
        }
        lines
    }
    client/src/stores/cart.ts:266اعرض الكود · 4
    export function quote(onUpdate?: (q: CartQuote) => void): Promise<CartQuote> {
      const lines = cart.get().lines;
      const sig = serialize(lines);
      const want = lines.map((l) => `${lineKey(l)}=${l.qty}`).join("|");
    12

    قوالب بيملاها TypeScript

    عميل TypeScript

    لما العميل يحتاج يعرض بيانات، زي تنبيه أو سطر في السلة أو اقتراح بحث، بياخد نسخة من <template id="tpl-…"> جاي من السيرفر، و fill()‎ بيحط كل [data-slot] كنص. الـ markup والكلام المترجم بتاعه متكتبين مرة واحدة، في Rust.

    ليه ده مهم هنامفيش نسخة تانية من الواجهة في العميل، ولا HTML متركّب من نصوص، ولا حاجة بتتحط كـ HTML. وأي تعديل في شكل كومبوننت بيتعمل في مكان واحد، والعميل بياخده.

    ديمو حي

      شكل السطر هو الـ <template> اللي فوق، Rust راسمه بلغتك. والعميل بس بيحط البيانات، كنص.

      الكود
      • client/src/core/dom.ts
      • crates/app/src/components/toast.rs
      client/src/core/dom.ts:32اعرض الكود · 15
      export function fill(name: string, values: Record<string, string | number | null | undefined>): DocumentFragment {
        const tpl = document.getElementById(`tpl-${name}`);
        if (!(tpl instanceof HTMLTemplateElement)) throw new Error(`missing <template id="tpl-${name}">`);
        const frag = tpl.content.cloneNode(true) as DocumentFragment;
        patch(frag, values);
        for (const el of $$("*", frag)) {
          for (const a of Array.from(el.attributes)) {
            if (!a.name.startsWith("data-attr-")) continue;
            const v = values[a.value];
            if (v !== null && v !== undefined) el.setAttribute(a.name.slice("data-attr-".length), String(v));
            el.removeAttribute(a.name);
          }
        }
        return frag;
      }
      crates/app/src/components/toast.rs:25اعرض الكود · 15
      <template id="tpl-toast">
          <li class="toast">
              <div class="toast__inner">
                  <Icon name="circle-check" size=20 class="toast__icon toast__icon--success"/>
                  <Icon name="circle-x" size=20 class="toast__icon toast__icon--error"/>
                  <Icon name="info" size=20 class="toast__icon toast__icon--info"/>
                  <p class="toast__msg" data-slot="msg"></p>
                  <button type="button" class="toast__action" data-toast-action="" data-slot="action"></button>
                  <button type="button" class="toast__close" data-toast-close=""
                      aria-label=crate::strings::get("toast.dismiss", lang)>
                      <Icon name="x" size=16/>
                  </button>
              </div>
          </li>
      </template>
      13

      كاش البيانات في العميل

      عميل TypeScript

      ‏core/cache.ts بيحتفظ بقراءات الـ API والصفحات في الميموري: طازة لمدة الـ TTL، وبعدها stale-while-revalidate (بتظهر على طول وتتحدّث في الخلفية)، وبعدين تتشال. والقراءات لنفس المفتاح في نفس الوقت بتشترك في طلب واحد، وكل مفتاح فيه المحل واللغة ونوع الأرقام.

      ليه ده مهم هنالما ترجع لصفحة أو تفتح الدرج تاني البيانات بتظهر على طول، وتغيير اللغة عمره ما يعرض صفحة متخزنة باللغة التانية. ولو السيرفر بعت generation جديد للكتالوج (x-catalog-gen)، كل حاجة بتتشال.

      ديمو حي

      المفتاح
      —
      العمر
      —
      stats()‎ في cache.ts
      —

        القيمة هي أرقام كاش السيرفر، ومتخزنة هنا 5 ثواني طازة و10 ثواني كمان قديمة. دوس كذا مرة وشوف الحالات التلاتة.

        الكود
        • client/src/core/cache.ts
        client/src/core/cache.ts:38اعرض الكود · 15
        export async function cached<T>(
          k: string,
          opts: CacheOpts,
          fetcher: () => Promise<T>,
          onUpdate?: (value: T) => void,
        ): Promise<T> {
          const hit = entries.get(k) as Entry<T> | undefined;
          const age = hit ? Date.now() - hit.at : Infinity;
          if (hit && age <= opts.ttl) {
            counts.hits++;
            return hit.value;
          }
          if (hit && age <= opts.ttl + (opts.swr ?? 0)) {
            counts.stale++;
            void load(k, opts, fetcher).then((v) => onUpdate?.(v), () => {});
        14

        حركة السبرينج من core/motion.ts

        عميل TypeScript

        ‏SpringValue بيحرّك رقم بسبرينج متخمّد، بيتحسب بالظبط في كل فريم. وتقدر تقاطعه: أي هدف جديد بيبدأ من القيمة والسرعة اللي عليها دلوقتي. والزقّة بتسلّم سرعتها لحظة ما تسيب، و project()‎ بيقول هتقف فين، و rubberband()‎ بيقاوم بعد الطرف.

        ليه ده مهم هناالأدراج والشيتات والجاليري بيمشوا مع صباعك وبيخرجوا من نفس السكة اللي دخلوا منها، زي iOS. وفيه تنفيذ واحد لكل الحركات، فمفيش حاجة بتتحرك بمنحنى مختلف.

        ديمو حي

        القيمة
        0
        السرعة
        0
        هيقف عند
        —

        اسحب النقطة وسيبها. لو سبتها براحة هتقف عند أقرب علامة؛ ولو زقيتها هتتحسب وهتقف مكان ما كانت هتهدى. وبعد أي طرف بتتمط زي الأستك.

        الكود
        • client/src/core/motion.ts
        client/src/core/motion.ts:40اعرض الكود · 8
        export const project = (velocity: number, deceleration = 0.998): number =>
          ((velocity / 1000) * deceleration) / (1 - deceleration);
        
        /** How far an element follows a drag `overshoot` px past an edge (Apple's constant 0.55). */
        export const rubberband = (overshoot: number, dimension: number, constant = 0.55): number => {
          const d = Math.max(dimension, 1);
          return (overshoot * d * constant) / (d + constant * Math.abs(overshoot));
        };
        client/src/features/showcase.ts:463اعرض الكود · 9
        // Release: hand the finger's velocity to the spring and aim where the flick would come to rest.
        const release = () => {
          if (!drag) return;
          drag = null;
          const v = Math.max(-4000, Math.min(4000, tracker.velocity()));
          const rest = spring.value + project(v);
          patch(root, { proj: t("showcase.px", { n: digits(Math.round(rest)) }) });
          aim(nearest(Math.min(Math.max(rest, 0), max)), v);
        };
        15

        حجم العميل

        عميل TypeScript

        كود المتصفح كله ES module واحد. بيتشيك عليه tsc، وبيجمّعه esbuild من crates/app/build.rs، وبيتحط جوه السيرفر نفسه، وبيتقدّم على ‎/js/app-….js‎ باسم فيه hash، فالمتصفح بيحتفظ بيه على طول.

        ليه ده مهم هناالميزانية للعميل كله 30 كيلوبايت gzip. وجزر الـ WASM اللي جه مكانها كانت بتكلّف من 379 لـ 769 كيلوبايت brotli في كل صفحة، قبل أي جزء خاص بالصفحة.

        ديمو حي

        الملف
        /js/app-4EHXELV3.js
        من غير ضغط، زي ما اتبنى
        0.9 KB
        الكود بس (من غير source map)، gzip
        —
        زي ما المتصفح بتاعك استلمه
        —
        العميل ده، gzip—
        الميزانية، gzip30 KB
        جزر WASM، نسخة مقسّمة، brotli379 KB
        جزر WASM، ملف واحد، brotli769 KB

        نسخة release: مصغّرة ومن غير source map.

        الكود
        • client/build.mjs
        • crates/app/build.rs
        • crates/app/src/client_js.rs
        crates/app/build.rs:31اعرض الكود · 7
        node(&["node_modules/typescript/bin/tsc", "--noEmit", "-p", "."]);
        let js_dir_s = js_dir.to_string_lossy().into_owned();
        let mut args = vec!["build.mjs", "--outdir", js_dir_s.as_str()];
        if release {
            args.push("--release");
        }
        node(&args);
        client/build.mjs:58اعرض الكود · 13
        const probe = (await build({ ...options, write: false })).metafile;
        const abs = (p) => path.resolve(p);
        const entryOut = Object.keys(probe.outputs).find((f) => probe.outputs[f].entryPoint && abs(probe.outputs[f].entryPoint) === entry);
        const firstLoad = new Set([entryOut]);
        for (const f of firstLoad) for (const i of probe.outputs[f].imports) if (i.kind === "import-statement" && !i.external) firstLoad.add(i.path);
        const firstLoadModules = [...firstLoad].flatMap((f) => Object.keys(probe.outputs[f].inputs)).map(abs).filter((m) => m !== entry);
        const onDemand = new Set(Object.values(probe.outputs).map((o) => o.entryPoint && abs(o.entryPoint)).filter((m) => m && m !== entry));
        
        // Pass 2: every on-demand feature also reaches the first-load modules → one shared chunk.
        const firstLoadChunk = {
          name: "first-load-chunk",
          setup(b) {
            b.onLoad({ filter: /\.tsx?$/ }, (args) => {

        قرارات

        عملنا إيه الأول، وكلّفنا كام، وليه غيّرنا الطريق.

        16

        ليه سبنا جزر الـ WASM

        قرارات

        النسخة الأولى كانت بجزر Leptos: كومبوننتس ‎#[island]‎ بتتحول WebAssembly وتصحى في المتصفح، والـ islands router هو اللي بيعمل التنقل. كانت شغالة، وقسناها في Chromium على سيرفر الـ release.

        ليه ده مهم هناكل صفحة كانت بتدفع من 379 لـ 769 كيلوبايت WASM ‏(brotli) في متجر أغلبه ثابت، والـ hydration كان فيه مشاكل توقيت بتبوّظ صفحات كاملة. ودي القياسات.

        ديمو حي

        ملف واحد، brotli
        769 KB
        نسخة مقسّمة، الملف الأساسي
        379 KB
        إعادة البناء بعد تعديل سطر
        36–48 ثانية (السيرفر لوحده: 15 ثانية)
        الصفحةملفات WASMWASM، brotli
        الرئيسية1379 KB
        السلة والدفع1379 KB
        الأقسام والبحث83578 KB
        المنتج107581 KB
        /ui37431 KB
        /showcase210713 KB
        • جزيرة لسه مستنية بيانات وقت الـ hydration كانت بتعمل panic بعد التنقل جوه الموقع، والـ panic كان بيوقف كل الجزر اللي بعدها، ومعاهم علامة السلة في الهيدر.
        • جزيرة lazy اتبنت من غير ‎--split‎ خلّت كود الـ JS يستورد module وهمي، فمفيش ولا جزيرة في أي صفحة صحيت.
        • قيم معمولة للمتصفح جوه جسم الكومبوننت كانت ممكن تتشال على thread تاني في السيرفر، فيحصل panic والـ HTML يتقطع، بشكل عشوائي.
        • الـ islands router كان محتاج حوالي 400 سطر ترقيع: ظبط الـ head، والـ <noscript>، وكاش المتصفح، وزرار الإرسال اللي اتداس، والـ scroll والرجوع.

        اللي جه مكانها متقاس دلوقتي في «حجم العميل». شوفه

        مستخدم في

        اتقاس يوم 26 أكتوبر 2026 على Leptos 0.8.22: نسخ release، والبايتات زي ما اتبعتت. التقرير الكامل في README.md.

        الكود
        • README.md

        سلتك

        المجموع
        وفّرت
        التوصيل
        التوصيل
        الإجمالي

        الأسعار شاملة ضريبة القيمة المضافة 14%.

        كمّل الطلبشوف السلة

        عدّل المنتج