ITSkillsCenter
الأعمال الرقمية

تتبع زمن حقيقي للسعاة عبر Server-Sent Events: درس 2026

6 دقائق للقراءة

📍 المقالة الرئيسية للمجموعة: Stack لوجستية 2026.

تتبع موقع الساعي في الوقت الحقيقي = أساس تجربة عميل حديثة. العميل يرى ساعيه يتقدم على خريطة، يقدر دقة دقيقة لـ ETA، يطمئن. تقنياً، نختار Server-Sent Events (SSE) بدلاً من WebSocket: أبسط، يعمل خلف proxies، خفيف على البطارية، إعادة اتصال تلقائية. هذا الدرس يفصل التنفيذ الكامل: backend Hono/Bun، PostGIS للاستعلامات الجغرافية، frontend Leaflet مع reactive updates.

المتطلبات

VPS Hetzner CX22 minimum مع PostgreSQL + PostGIS extension. Bun 1.2+ أو Node.js 22+. معرفة TypeScript، APIs، WebSocket/SSE. المستوى المتوقع: متقدم. الوقت المقدر: 4-6 ساعات للتنفيذ الأساسي.

الخطوة 1 — تثبيت PostGIS

PostGIS يضيف أنواع جغرافية إلى PostgreSQL: نقاط، مضلعات، استعلامات «المسافة بين»، «داخل دائرة قطرها 5 كم». ضروري للوجستية.

apt install -y postgresql-16-postgis-3
sudo -u postgres psql
CREATE DATABASE logistique;
\c logistique
CREATE EXTENSION postgis;
CREATE EXTENSION btree_gist;

الخطوة 2 — Schema قاعدة البيانات

الجداول الأساسية: couriers (السعاة)، deliveries (التوصيلات)، locations (مواقع GPS). الفهارس spatial أساسية للأداء.

CREATE TABLE couriers (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL,
  phone text NOT NULL,
  vehicle_type text DEFAULT 'motorcycle',
  current_location geography(Point, 4326),
  last_seen_at timestamptz,
  status text DEFAULT 'available'  -- available, busy, offline
);

CREATE INDEX idx_couriers_location ON couriers USING gist(current_location);

CREATE TABLE deliveries (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  courier_id uuid REFERENCES couriers(id),
  customer_phone text NOT NULL,
  pickup_location geography(Point, 4326) NOT NULL,
  dropoff_location geography(Point, 4326) NOT NULL,
  pickup_address text,
  dropoff_address text,
  status text DEFAULT 'pending',  -- pending, picked_up, in_transit, delivered
  created_at timestamptz DEFAULT now(),
  picked_up_at timestamptz,
  delivered_at timestamptz,
  amount integer,
  currency text DEFAULT 'XOF',
  payment_method text  -- cash, wave, om
);

CREATE TABLE location_history (
  id bigserial PRIMARY KEY,
  courier_id uuid REFERENCES couriers(id),
  delivery_id uuid REFERENCES deliveries(id),
  location geography(Point, 4326) NOT NULL,
  speed numeric,  -- km/h
  recorded_at timestamptz DEFAULT now()
);

CREATE INDEX idx_history_courier ON location_history(courier_id, recorded_at DESC);

الخطوة 3 — Backend Hono/Bun

Hono إطار خفيف وفعال على Bun. يدعم SSE أصلياً منذ v3.

// src/server.ts
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import postgres from 'postgres';

const app = new Hono();
const sql = postgres(process.env.DATABASE_URL!);

// Endpoint استلام GPS من الساعي
app.post('/api/courier/:id/location', async (c) => {
  const courierId = c.req.param('id');
  const { lat, lng, speed, deliveryId } = await c.req.json();
  
  // تحديث الموقع الحالي
  await sql`
    UPDATE couriers 
    SET current_location = ST_MakePoint(${lng}, ${lat})::geography,
        last_seen_at = now()
    WHERE id = ${courierId}
  `;
  
  // إضافة إلى التاريخ
  await sql`
    INSERT INTO location_history (courier_id, delivery_id, location, speed)
    VALUES (${courierId}, ${deliveryId}, ST_MakePoint(${lng}, ${lat})::geography, ${speed})
  `;
  
  // بث إلى مشتركي SSE
  broadcastLocation(courierId, { lat, lng, speed });
  
  return c.json({ ok: true });
});

// Endpoint SSE للعميل النهائي
app.get('/api/delivery/:id/track', (c) => {
  return streamSSE(c, async (stream) => {
    const deliveryId = c.req.param('id');
    const subscriber = subscribe(deliveryId);
    
    for await (const event of subscriber) {
      await stream.writeSSE({
        event: 'location',
        data: JSON.stringify(event)
      });
    }
  });
});

export default app;

الخطوة 4 — Pub/Sub داخلي

كل تحديث موقع يُبث إلى المشتركين النشطين. EventEmitter بسيط أو Redis Pub/Sub لـ multi-instance.

// src/pubsub.ts
import { EventEmitter } from 'node:events';

const emitter = new EventEmitter();
const subscribers = new Map<string, Set<AsyncIterableIterator<any>>>();

export function broadcastLocation(courierId: string, location: any) {
  emitter.emit(`courier:${courierId}`, location);
  
  // إيجاد التوصيلات النشطة لهذا الساعي
  const activeDeliveries = getActiveDeliveriesForCourier(courierId);
  for (const deliveryId of activeDeliveries) {
    emitter.emit(`delivery:${deliveryId}`, location);
  }
}

export async function* subscribe(deliveryId: string) {
  const queue: any[] = [];
  let resolve: () => void;
  let waiting = new Promise<void>(r => resolve = r);
  
  const handler = (data: any) => {
    queue.push(data);
    resolve();
    waiting = new Promise<void>(r => resolve = r);
  };
  
  emitter.on(`delivery:${deliveryId}`, handler);
  
  try {
    while (true) {
      while (queue.length) yield queue.shift();
      await waiting;
    }
  } finally {
    emitter.off(`delivery:${deliveryId}`, handler);
  }
}

الخطوة 5 — Frontend client (Leaflet)

Leaflet خفيف، مفتوح المصدر، يعمل دون مفتاح API (مقابل Mapbox). MapLibre GL أحدث للـ vector tiles لكنه أثقل.

// app/track/[deliveryId]/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Polyline } from 'react-leaflet';
import L from 'leaflet';

const courierIcon = L.icon({
  iconUrl: '/courier-motorcycle.png',
  iconSize: [40, 40]
});

export default function TrackPage({ params }: { params: { deliveryId: string } }) {
  const [location, setLocation] = useState<{ lat: number; lng: number } | null>(null);
  const [path, setPath] = useState<[number, number][]>([]);
  
  useEffect(() => {
    const eventSource = new EventSource(
      `/api/delivery/${params.deliveryId}/track`
    );
    
    eventSource.addEventListener('location', (e) => {
      const data = JSON.parse(e.data);
      setLocation(data);
      setPath(prev => [...prev, [data.lat, data.lng]]);
    });
    
    eventSource.onerror = () => {
      // إعادة اتصال تلقائية بعد 3 ثوان
      setTimeout(() => eventSource.close(), 3000);
    };
    
    return () => eventSource.close();
  }, [params.deliveryId]);
  
  if (!location) return <div>جاري الاتصال بالساعي...</div>;
  
  return (
    <MapContainer center={[location.lat, location.lng]} zoom={15} style={{ height: '100vh' }}>
      <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
      <Marker position={[location.lat, location.lng]} icon={courierIcon} />
      <Polyline positions={path} color="blue" />
    </MapContainer>
  );
}

الخطوة 6 — حساب ETA

ETA (Estimated Time of Arrival) يحسب على كل تحديث GPS. استخدم OSRM أو formule Haversine + متوسط السرعة.

app.get('/api/delivery/:id/eta', async (c) => {
  const deliveryId = c.req.param('id');
  
  // الحصول على موقع الساعي ووجهته
  const [delivery] = await sql`
    SELECT 
      c.current_location,
      d.dropoff_location,
      ST_Distance(c.current_location, d.dropoff_location) as distance_meters
    FROM deliveries d
    JOIN couriers c ON c.id = d.courier_id
    WHERE d.id = ${deliveryId}
  `;
  
  // استدعاء OSRM
  const osrmRes = await fetch(
    `http://localhost:5000/route/v1/driving/${courierLng},${courierLat};${dropoffLng},${dropoffLat}`
  );
  const route = await osrmRes.json();
  const durationSeconds = route.routes[0].duration;
  
  return c.json({
    eta_minutes: Math.ceil(durationSeconds / 60),
    distance_km: (delivery.distance_meters / 1000).toFixed(1)
  });
});

الخطوة 7 — Throttling لتوفير البطارية

إرسال GPS كل ثانية = استنزاف بطارية + تكلفة data. Throttle: كل 10 ثوان عند الحركة، كل 60 ثانية عند الوقوف. تكامل في تطبيق الساعي:

// app courier
let lastSent = 0;
let lastLocation: GeolocationPosition | null = null;

navigator.geolocation.watchPosition(
  (position) => {
    const now = Date.now();
    const moving = lastLocation && distance(lastLocation, position) > 5; // 5m
    const interval = moving ? 10000 : 60000;
    
    if (now - lastSent > interval) {
      sendLocation(position);
      lastSent = now;
      lastLocation = position;
    }
  },
  null,
  { enableHighAccuracy: true, maximumAge: 5000 }
);

الخطوة 8 — Fallback offline

الساعي يفقد الشبكة في زقاق. التطبيق يخزن مواقع GPS في IndexedDB، يبعث عند العودة.

// app courier offline mode
const queueLocation = async (location: any) => {
  if (navigator.onLine) {
    try {
      await sendLocation(location);
    } catch {
      await db.locations.add({ ...location, queued: true });
    }
  } else {
    await db.locations.add({ ...location, queued: true });
  }
};

// المزامنة عند العودة
window.addEventListener('online', async () => {
  const queued = await db.locations.where('queued').equals(1).toArray();
  for (const loc of queued) {
    await sendLocation(loc);
    await db.locations.delete(loc.id);
  }
});

الخطوة 9 — تنبيهات SMS

الزبون لا يبقى دائماً على صفحة التتبع. SMS عند تغيير الحالة الرئيسي:

app.post('/api/delivery/:id/status', async (c) => {
  const { status } = await c.req.json();
  await sql`UPDATE deliveries SET status = ${status} WHERE id = ${deliveryId}`;
  
  if (['picked_up', 'in_transit', 'delivered'].includes(status)) {
    const messages = {
      picked_up: 'تم استلام طلبك، الساعي في الطريق',
      in_transit: 'الساعي في الطريق إليك',
      delivered: 'تم تسليم طلبك. شكراً!'
    };
    await sendSMS(customerPhone, messages[status]);
  }
});

الخطوة 10 — Dashboard المدير

لوحة تعرض كل السعاة على خريطة في الوقت الحقيقي. SSE multi-courier:

app.get('/api/admin/all-couriers', (c) => {
  return streamSSE(c, async (stream) => {
    // جلب أولي
    const couriers = await sql`
      SELECT id, name, ST_AsGeoJSON(current_location) as geo
      FROM couriers WHERE status != 'offline'
    `;
    await stream.writeSSE({ event: 'init', data: JSON.stringify(couriers) });
    
    // الاشتراك في كل التحديثات
    emitter.on('location:any', async (event) => {
      await stream.writeSSE({ event: 'update', data: JSON.stringify(event) });
    });
  });
});

الأخطاء الشائعة

الخطأ السبب الحل
SSE قطع على proxy nginx buffering ON proxy_buffering off
إعادة اتصال loop EventSource error backoff exponentielle
GPS يستنزف البطارية watchPosition دون throttle 10s/60s adaptive
PostGIS query بطيء لا index spatial CREATE INDEX gist
OSRM crash OOM خرائط كبيرة CCX13 minimum
تأخير 30 ثانية SSE buffered flush headers + chunks

التكيف مع السياق

أربع توضيحات. 3G/4G متذبذب. اتصال يقطع في الزقاق. SSE مع reconnect + offline queue ضروري. هواتف منخفضة الجودة. Leaflet أخف من Mapbox. تجنب vector tiles ثقيلة. data 4G مكلف. compression gzip على SSE. throttling adaptive يقلل usage 80%. عناوين دون رقم. Plus Codes Google (10-character codes) مفيدة. الساعي يدخل code، الخريطة تذهب مباشرة.

دروس الإخوة

الأسئلة المتكررة

SSE vs WebSocket؟ SSE أبسط، يعمل خلف proxies، إعادة اتصال تلقائية. WebSocket bidirectional لكنه أثقل. SSE مثالي للوجستية (server → client unidirectional).

دقة GPS؟ 10-20 متر في المدينة. كافٍ للوجستية. PDOP (Precision Dilution of Precision) في زقاق ضيق قد يصل 50م.

سعة CX22؟ 50 سعاة + 200 توصيل/يوم مريح. ما بعد ذلك CCX13.

تكلفة Twilio SMS؟ 0.04-0.08 USD/SMS في إفريقيا الغربية. لـ 5,000 SMS/شهر = 200-400 USD. Africa’s Talking أرخص.

للاستزادة

Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité