التوثيق
الشبكات
تُجري تطبيقات NSH طلبات HTTP باستخدام fetch: في Rust عبر الدالة nsh_core::prelude التي يُنتظر ناتجها داخل مهمة (task)، وفي TSX عبر الدالة العامة القياسية. يعمل كلاهما فوق حزمة الشبكة الخاصة بالمنصة (NSURLSession على iOS، وHttpURLConnection على Android، وما يوفره المتصفح على الويب)، لذلك تستخدم الطلبات TLS والوكلاء (proxies) وHTTP/2 الخاصة بالنظام، ويمر كلاهما عبر فحوصات القدرات نفسها. تقبل fetch في Rust عنوان URL أو HttpRequest وتعيد الاستجابة كاملة (HttpResponse: status()، ok()، header(name)، text()، body). أما في TSX فالبث يعمل في الاتجاهين: يصل جسم الاستجابة كتدفق (stream) كما تسلّمه حزمة الشبكة، ويمكن أن يكون جسم الطلب تدفقًا يُرسل أثناء إنتاجه. ويوفر TSX أيضًا ما يرافق Fetch API: Headers، Request، Response، AbortController، FormData، Blob، File، URL، URLSearchParams، TextEncoder / TextDecoder وWeb Streams.
#الحصول على JSON
use nsh_core::prelude::*;
#[derive(Clone, PartialEq)]
struct Post {
id: u64,
title: String,
}
fn parse(body: &str) -> Result<Vec<Post>, String> {
let posts: Vec<serde_json::Value> = serde_json::from_str(body).map_err(|e| e.to_string())?;
Ok(posts
.iter()
.map(|p| Post {
id: p["id"].as_u64().unwrap_or_default(),
title: p["title"].as_str().unwrap_or_default().to_owned(),
})
.collect())
}
#[component]
fn App() -> impl IntoElement {
let posts = resource(
|| Some(()),
|(), _| async move {
let response = fetch("https://jsonplaceholder.typicode.com/posts?_limit=5").await?;
if !response.ok() {
return Err(format!("HTTP {}", response.status()));
}
parse(&response.text())
},
);
scroll().flex_1().child(
view()
.p_4()
.pt(px(60.0))
.gap_2()
.child(
text(move || {
if posts.loading() { "loading…".to_owned() } else { posts.error().unwrap_or_default() }
})
.text_color(rgb(0x57606a)),
)
.child(each(
move || posts.get().unwrap_or_default(),
|post| post.id,
|post, _| text(move || post.with(|p| p.title.clone())).number_of_lines(1),
)),
)
}import { mount, resource, Scroll, Text, View } from "nsh";
type Post = { id: number; title: string };
function App() {
const posts = resource(async ({ signal }) => {
const response = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=5", { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as Post[];
});
return (
<Scroll style={{ flex: 1 }}>
<View style={{ padding: 16, paddingTop: 60, gap: 8 }}>
<Text style={{ color: "#57606a" }}>{posts.loading() ? "loading…" : String(posts.error() ?? "")}</Text>
<Each of={posts() ?? []}>{(post) => <Text style={{ numberOfLines: 1 }}>{post().title}</Text>}</Each>
</View>
</Scroll>
);
}
mount(App);يحتفظ resource بالقيمة والخطأ وحالة تشغيل التحميل، ويعيد التحميل عندما يتغير المصدر (أو عند refetch())، ويلغي أي تحميل لم يعد مطلوبًا (عند زوال المكوّن أو بدء تحميل أحدث): في Rust بإسقاط مهمة التحميل مما يلغي طلبها، وفي TSX عبر signal الذي يمرّره. تقبل fetch في Rust عناوين URL مطلقة فقط؛ ويُحلَّل JSON بواسطة crate مثل serde_json.
في TSX، يقرأ Response جسمه مرة واحدة فقط، على هيئة json() أو text() أو arrayBuffer() أو bytes() أو blob() أو formData()، أو كتدفق body. تكون response.ok صحيحة (true) لحالات 2xx؛ وكما في المتصفحات، لا تؤدي حالة خطأ HTTP إلى رفض الـ promise.
#إرسال البيانات
use nsh_core::prelude::*;
// JSON
let body = serde_json::json!({ "title": "Groceries", "items": ["milk", "eggs"] });
let created = fetch(
HttpRequest::post("https://api.example.com/notes", body.to_string())
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {token}")),
)
.await?;
// A form, URL-encoded
fetch(
HttpRequest::post("https://api.example.com/login", "user=ada&password=…")
.header("Content-Type", "application/x-www-form-urlencoded"),
)
.await?;
// Other methods
fetch(HttpRequest::new("DELETE", "https://api.example.com/notes/1")).await?;// JSON
const created = await fetch("https://api.example.com/notes", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ title: "Groceries", items: ["milk", "eggs"] }),
});
// A form, URL-encoded
await fetch("https://api.example.com/login", {
method: "POST",
body: new URLSearchParams({ user: "ada", password: "…" }),
});
// Query strings
const url = new URL("https://api.example.com/search");
url.searchParams.set("q", "native shell");
url.searchParams.set("page", "2");
const results = await (await fetch(url)).json();HttpRequest::new(method, url) وget وpost تُنشئ طلبًا، و.header(..) و.body(..) تُكمله. في TSX، يقبل body سلسلة نصية أو URLSearchParams أو FormData أو Blob أو File أو ArrayBuffer أو مصفوفة مطبّعة (typed array) أو ReadableStream.
#رفع الملفات باستخدام FormData
في TSX، يُرسل جسم FormData بنوع multipart/form-data مع الحدّ الفاصل (boundary). يضبط NSH نوع المحتوى هذا دائمًا لجسم FormData، حتى لو ضبطت مكتبة Content-Type افتراضيًا أولًا، لأن الخادم لا يستطيع تحليل الجسم بدون الحدّ الفاصل.
const form = new FormData();
form.append("title", "Sunset");
form.append("photo", new Blob([bytes], { type: "image/jpeg" }), "sunset.jpg");
const response = await fetch("https://api.example.com/photos", { method: "POST", body: form });
#رفع صورة يختارها المستخدم
يوفر Picker الصور التي يختارها المستخدم كملفات تبقى بياناتها في الجانب الأصلي (native)، مصغّرة
وبصيغة تقبلها الخوادم؛ يرسل Rust بايتاتها، ويضيف TSX الكائن File إلى FormData:
use nsh_core::geometry::DevicePixels;
use nsh_core::picker::PickRequest;
use nsh_core::prelude::*;
let mut request = PickRequest::new(false, 1);
request.max_dimension = Some(DevicePixels(2048));
let picked = pick_images(request);
spawn(async move {
if let Some(photo) = picked.await.into_iter().next() {
let upload = HttpRequest::post("https://api.example.com/photos", photo.data.to_vec())
.header("Content-Type", photo.mime.as_str());
let _ = fetch(upload).await;
}
});import { Picker } from "nsh";
const [photo] = await Picker.pickImages({ maxDimension: 2048 });
if (photo) {
const form = new FormData();
form.append("photo", photo.file);
await fetch("https://api.example.com/photos", { method: "POST", body: form });
}#البث
يبث fetch في TSX الاستجابات وأجسام الطلبات؛ أما في Rust فيُعاد الجسم كاملًا مع الاستجابة.
#قراءة الاستجابة أثناء وصولها
response.body هو ReadableStream<Uint8Array> يمتلئ كلما سلّمت الشبكة بايتات. اقرأه قطعة قطعة لعرض التقدم أو لمعالجة الأسطر المرسلة من الخادم (server-sent) فور وصولها:
const response = await fetch("https://api.example.com/large.csv");
const total = Number(response.headers.get("Content-Length") ?? 0);
let received = 0;
for await (const chunk of response.body!) {
received += chunk.length;
progress.set(total ? received / total : 0);
}
تُفكّ ترميز تدفقات النص باستخدام TextDecoderStream، ويقسمها TransformStream إلى أسطر:
const response = await fetch("https://api.example.com/events", { headers: { Accept: "text/event-stream" } });
let rest = "";
const lines = response
.body!.pipeThrough(new TextDecoderStream())
.pipeThrough(
new TransformStream<string, string>({
transform(chunk, controller) {
const parts = (rest + chunk).split("\n");
rest = parts.pop() ?? "";
for (const line of parts) controller.enqueue(line);
},
}),
);
for await (const line of lines) {
if (line.startsWith("data: ")) messages.update((m) => [...m, line.slice(6)]);
}
يعمل الضغط العكسي (back pressure) من البداية إلى النهاية: عندما ينتظر أكثر من 1 MiB من الاستجابة دون قراءة، يُوقف الطلب مؤقتًا على مستوى الشبكة حتى يلحق الكود الخاص بك بالقراءة.
#بث جسم الطلب
يُرسل جسم ReadableStream قطعة قطعة كلما طلبته حزمة الشبكة (النقل المجزّأ، chunked transfer). مرّر duplex: "half" كما يشترط معيار Fetch:
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
for (const line of logLines) controller.enqueue(encoder.encode(line + "\n"));
controller.close();
},
});
await fetch("https://api.example.com/logs", { method: "POST", body, duplex: "half" });
لا يمكن إرسال جسم مبثوث مرتين، لذلك يفشل تحويل 307 أو 308 لطلب مبثوث.
#الإلغاء ومهلات الانتظار
في Rust، يعمل الطلب داخل مهمة ويُلغى معها: عند زوال مكوّن المهمة، أو باستخدام cancel() على المهمة التي تعيدها spawn. في TSX، مرّر AbortSignal لإلغاء الطلب؛ يُرفض الـ promise بسبب الإشارة (AbortError من نوع DOMException افتراضيًا)، ويتوقف أي جسم تجري قراءته.
let task = spawn(async move {
if let Ok(response) = fetch(format!("https://api.example.com/search?q={query}")).await {
results.set(response.text());
}
});
// Typing again: cancel the previous search.
task.cancel();const controller = new AbortController();
const request = fetch("https://api.example.com/search?q=" + encodeURIComponent(query), { signal: controller.signal });
// Typing again: cancel the previous search.
controller.abort();في TSX، ينشئ AbortSignal.timeout(ms) إشارة تُجهض بعد مهلة، ويجمع AbortSignal.any([...]) بين عدة إشارات:
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
ينبغي للمكوّن الذي يحمّل بيانات أن يلغي عند زواله: مهمة Rust تفعل ذلك تلقائيًا، أما TSX فيُجهض في cleanup:
use nsh_core::prelude::*;
fn weather(city: String) -> impl IntoElement {
let data = signal(String::new());
// The task, and its request, end when the component goes away.
spawn(async move {
if let Ok(response) = fetch(format!("https://api.example.com/weather?city={city}")).await {
data.set(response.text());
}
});
text(move || data.get())
}import { cleanup, signal, Text } from "nsh";
function Weather(props: { city: string }) {
const data = signal<unknown>(null);
const controller = new AbortController();
cleanup(() => controller.abort());
fetch(`https://api.example.com/weather?city=${encodeURIComponent(props.city)}`, { signal: controller.signal })
.then((r) => r.json())
.then((d) => data.set(d))
.catch(() => {});
return <Text>{JSON.stringify(data())}</Text>;
}#الأخطاء
يفشل الطلب الذي لا ينتج استجابة HTTP: تعيد fetch في Rust Err مع الرسالة، وفي TSX يُرفض الـ promise بـ TypeError تبدأ رسالته بـ Network request failed:. يحدث ذلك عند انعدام الاتصال، أو فشل DNS أو TLS، أو استخدام مخطط URL غير مدعوم، أو طلب لا تسمح به قدرات تطبيقك. تحقق من ok() / status() (في TSX: response.ok، response.status) لمعرفة أخطاء HTTP.
match fetch(url).await {
Ok(response) if response.ok() => items.set(response.text()),
Ok(response) => error.set(format!("Server answered {}", response.status())),
Err(message) => error.set(message), // no response, or not granted
}try {
const response = await fetch(url);
if (!response.ok) throw new Error(`Server answered ${response.status}`);
return await response.json();
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") return; // cancelled
showError(String(e));
}#من مكونات Rust
تستدعي مكونات Rust ودوال #[module] وأعضاء #[class] الدالة fetch من داخل مهمة: spawn في المكوّن، أو resource لقيمة تعرضها الواجهة، أو async fn مصدَّرة إلى TSX ينتظرها TSX كـ Promise. الطلب عبارة عن عنوان URL أو HttpRequest (HttpRequest::get(url)، HttpRequest::post(url, body)، .header(name, value))، وتصل استجابته كاملة. تمتلك المهمة الطلب: إسقاطها، أو زوال المكوّن الذي أطلقها، يلغي الطلب. وتستخدم حزمة الشبكة وفحوصات القدرات نفسها المستخدمة في TSX، على كل المنصات وفي المتصفح.
#القدرات: المضيفات التي يجوز للتطبيق الوصول إليها
يمكن للتطبيق أن يصرّح في nsh.json بالمضيفات التي يتواصل معها. بمجرد وجود "capabilities"، يُرفض كل ما لم يُدرج:
{
"capabilities": {
"network": {
"hosts": ["api.example.com", "*.cdn.example.com"],
"http": [
{ "host": "quotes.example.com", "methods": ["GET"], "pathPrefix": "/v1/" }
]
}
}
}
| المفتاح | الوصف |
|---|---|
network.hosts | المضيفات التي يمكن الوصول إليها بأي طريقة: "*" (أي مضيف)، أو "*.example.com" (نطاقاتها الفرعية، دون example.com نفسه) أو اسم مضيف. |
network.http | منح أدق: host، ويمكن تقييده بـ methods وschemes وport وpaths المطابق وpathPrefixes. |
تُفرض الفحوصات في بيئة تشغيل NSH، خارج كود تطبيقك، قبل أن يصل الطلب إلى الشبكة:
-
يفشل
fetchالمرفوض (في TSX بـTypeError، كأي طلب فاشل) مع رسالة تذكر المفتاح الذي يجب إضافته، مثلNetwork request failed: fetch GET https://evil.io/ is not granted: add `evil.io` to capabilities.network in nsh.json. في TSX تكون قيمةcodeفي الخطأ"ERR_ACCESS_DENIED"، وقيمةpermissionهي"network"، وقيمةresourceهي عنوان URL المرفوض (عنوان الطلب أو عنوان تحويل)، فيستطيع التطبيق التمييز بين الرفض وانقطاع الاتصال:try { await fetch(url); } catch (error) { if (error.code === "ERR_ACCESS_DENIED") console.warn(`not granted: ${error.resource}`); else throw error; } -
يُفحص كل تحويل (redirect) من جديد. يتبع NSH التحويلات بنفسه (حتى 20 تحويلًا) بدل ترك ذلك للمنصة، لذلك لا يستطيع مضيف مسموح أن يحوّل تطبيقك إلى مضيف غير مسموح.
-
تغطي منح
networkنفسها الصور البعيدة (بما في ذلك داخل canvases وImage.getSize/prefetch) ومصادر الفيديو. أما عناوينasset:الخاصة بحزمة تطبيقك فهي مسموحة دائمًا. -
يُؤخذ المضيف بعد آخر
@، لذلك يُحتسبhttps://ok.com@evil.ioعلى أنهevil.io.
يمكن للتطبيق الذي لا يملك "capabilities" الوصول إلى كل المضيفات: فالتطبيق المستقل ملك مطوّره. أما التطبيقات المضمّنة في تطبيق مضيف أو الموزّعة كـ حزم موقّعة فينبغي أن تصرّح بها؛ ويمكن للتطبيق المضيف أن يمنح أقل مما يصرّح به التطبيق، ويجب أن يسمح الطرفان بالطلب. ويفرض التطوير باستخدام nsh dev / nsh run القدرات نفسها. راجع صندوق الحماية والقدرات.
#الاختلافات عن المتصفحات
يتبع fetch في TSX معيار Fetch مع الاختلافات المنطقية لتطبيق وليس لصفحة ويب:
- لا CORS. يتواصل التطبيق مع أي مضيف تسمح به قدراته؛ لا توجد طلبات تمهيدية (preflight) ولا استجابات معتمة (opaque).
- لا ترويسات محظورة. يمكن للتطبيق ضبط
CookieوUser-AgentوOriginوغيرها. redirect: "manual"تعيد استجابة 3xx نفسها (مع ترويسةLocation) بدل تحويل معتم. ويرفضredirect: "error"عند حدوث تحويل.- عناوين
data:وblob:تُجاب محليًا. ينشئURL.createObjectURL(blob)عنوانblob:يستطيعfetchقراءته إلى أن تلغيه. - عناوين
asset:/هي الملفات التي يشحنها التطبيق، وتُقرأ من ملفات التطبيق نفسه دون الحاجة إلى أي قدرة (قراءة ملف أثناء التشغيل). - الخيارات الخاصة بالمتصفح فقط (
mode،credentials،cache،referrer،integrity،keepalive،priority) مقبولة للتوافق مع المكتبات.
المكتبات المبنية على fetch (وعلى AbortController وFormData والتدفقات) تعمل عمومًا دون تعديل. لا يوجد XMLHttpRequest؛ فالمضيفات الأصلية توفر الدالة العامة WebSocket ووحدة websocket، وتُفحص وفق المنح نفسها المطبقة على fetch.
#ذات صلة
- واجهات API العامة: كل الدوال والكائنات العامة مع أعضائها.
- صندوق الحماية والقدرات: القدرات والحدود وكيف يمنحها التطبيق المضمِّن.
- الصور والأصول: الصور البعيدة.
- القوائم: التنقل بين صفحات API.