الحزمة في أي تطبيق Node
استخدام @dukkan.one/app-sdk من دون Nuxt: العميل ومخزن الرموز وقفزتا OAuth ومستقبِل Webhooks على Node أو Workers أو Bun أو Deno.
في هذه الصفحة
هذه الصفحة للمطوّرين الذين ليس تطبيقهم مشروع Nuxt: خدمة Express أو Hono، أو Cloudflare Worker، أو مستهلك طابور. في نهايتها تكون لديك القطع الأربع التي تربطها وحدة Nuxt عادةً نيابةً عنك، كل واحدة في بضعة أسطر، على أي بيئة تشغيل فيها fetch وWebCrypto.
التثبيت#
npm install @dukkan.one/app-sdkللحزمة اعتماديتان (zod وopenapi-fetch)، وتأتي بصيغتي ESM وCommonJS مع الأنواع، وتعمل على Node.js 18.17 أو أحدث وCloudflare Workers وBun وDeno. لا يستورد اللبّ أي وحدة مدمجة في Node؛ ويأخذ محوّل Postgres عميل postgres الخاص بك.
احفظ الرموز#
لا تخزّن الحزمة الرموز بنفسها أبداً. تنفّذ TokenStore مرة واحدة، أو تستخدم محوّلاً:
import postgres from "postgres";
import { createAesGcmSealer } from "@dukkan.one/app-sdk";
import { createPostgresTokenStore } from "@dukkan.one/app-sdk/tokens/postgres";
const sql = postgres(process.env.DATABASE_URL!);
export const tokenStore = createPostgresTokenStore({
sql,
table: "installs",
sealer: createAesGcmSealer({ currentKey: process.env.APP_ENCRYPTION_KEY! }),
});import { createAesGcmSealer, createKeyValueTokenStore } from "@dukkan.one/app-sdk";
export const tokenStore = createKeyValueTokenStore({
backend: {
get: (key) => env.TOKENS.get(key),
put: (key, value) => env.TOKENS.put(key, value),
delete: (key) => env.TOKENS.delete(key),
// Optional but strongly recommended: an atomic lock across instances.
putIfAbsent: async (key, value, { ttlSeconds }) => acquire(key, value, ttlSeconds),
},
sealer: createAesGcmSealer({ currentKey: env.APP_ENCRYPTION_KEY }),
});الجدول المرجعي لمحوّل Postgres:
create table installs (
install_id uuid primary key,
store_id uuid not null,
access_token_enc text not null,
access_expires_at timestamptz not null,
refresh_token_enc text not null,
refresh_expires_at timestamptz,
scopes text[] not null default '{}',
reconnect_required text,
updated_at timestamptz not null default now()
);withRefreshLock هي الجزء المهم: تلغي المنصّة التثبيت عندما يُقدَّم رمز تحديث مدوَّر مرة ثانية، فلا يجوز لنسختين أن تجدّدا التثبيت نفسه في الوقت نفسه. يقفل محوّل Postgres الصف؛ ويحتاج المحوّل المفتاحي إلى putIfAbsent للضمان نفسه.
قفزتا OAuth#
import { buildAuthorizeUrl, generatePkce, generateState } from "@dukkan.one/app-sdk/oauth";
export async function install(request: Request): Promise<Response> {
const pkce = await generatePkce();
const state = generateState();
await stash.put(state, { verifier: pkce.verifier }, { ttlSeconds: 600 }); // server-side, never in the URL
return Response.redirect(buildAuthorizeUrl({
clientId: env.DUKKAN_CLIENT_ID,
redirectUri: "https://app.example.com/dukkan/callback",
scopes: ["orders:read", "orders:write"],
state,
codeChallenge: pkce.challenge,
installToken: new URL(request.url).searchParams.get("install_token"),
}), 302);
}import { exchangeCode, parseCallbackQuery } from "@dukkan.one/app-sdk/oauth";
import { tokensFromResponse } from "@dukkan.one/app-sdk";
export async function callback(request: Request): Promise<Response> {
const query = parseCallbackQuery(new URL(request.url).searchParams);
const stashed = await stash.take(query.state);
if (!query.ok || !stashed) return new Response("state mismatch", { status: 400 });
const response = await exchangeCode({
credentials: { clientId: env.DUKKAN_CLIENT_ID, clientSecret: () => env.DUKKAN_CLIENT_SECRET },
code: query.code,
redirectUri: "https://app.example.com/dukkan/callback",
codeVerifier: stashed.verifier,
});
await tokenStore.save(tokensFromResponse(response.install_id!, null, response));
return mintSession(response.install_id!, response.store_id!);
}تحمل استجابة الرموز install_id وstore_id وstore_slug؛ راجع التفويض. اجعل المعرّفات مفتاح كل شيء.
استدعِ الواجهة#
import { createDukkanClient, DukkanReconnectRequiredError } from "@dukkan.one/app-sdk";
const client = createDukkanClient({
installId,
tokenStore,
credentials: { clientId: env.DUKKAN_CLIENT_ID, clientSecret: () => env.DUKKAN_CLIENT_SECRET },
});
try {
for await (const order of client.orders.iterate({ status: "placed" })) {
await enqueue(order.id);
}
await client.orders.updateStatus(orderId, "approved", { idempotency: { ref: [orderId, jobId] } });
} catch (error) {
if (error instanceof DukkanReconnectRequiredError) await markReconnect(installId);
else throw error;
}تعيد القراءات والكتابات الآمنة للتكرار المحاولة عند 429 و5xx بتراجع متدرّج وتحترم Retry-After. تحمل كل كتابة Idempotency-Key: عشوائياً افتراضياً ومعاداً عبر محاولات الحزمة نفسها، أو مشتقاً من مرجعك أنت بـ idempotency: { ref } فترسل المهمة المعادة المفتاح نفسه. كل فشل خطأ منمّط يحمل code وstatus وrequestId وdetails من المنصّة؛ القائمة في مرجع حزمة التطبيقات.
استقبل Webhooks#
import { createWebhookReceiver } from "@dukkan.one/app-sdk/webhooks";
const receiver = createWebhookReceiver({
secretsFor: async (installId) => {
const row = await loadInstall(installId);
return row ? { secrets: [row.webhookSecret], storeId: row.storeId } : null;
},
inbox: { insertIfNew: (record) => insertIgnoringDuplicates(record) },
handlers: {
"order.paid": async (event) => { await recordReceipt(event.data.order_id, event.data.amount_minor); },
"app.uninstalled": async (event) => { await purge(event.installId); },
},
});
export async function webhooks(request: Request, context: { waitUntil(p: Promise<unknown>): void }): Promise<Response> {
const rawBody = await request.arrayBuffer();
const { response, process } = await receiver.receive({ rawBody, headers: request.headers });
context.waitUntil(process());
return Response.json(response.body, { status: response.status });
}يتحقق المستقبِل من HMAC على البايتات الخام بوقت ثابت، ويرفض الطوابع الزمنية القديمة والأجسام الأكبر من 256 KB، ويربط التسليم بالتثبيت المذكور داخل التوقيع، ويزيل التكرار على معرّف الحدث الموقّع، ثم يرسل إلى المعالجات. أجب بما يعيده؛ الحالات تطابق التسليم وإعادة المحاولة.
تحذير
مرّر جسم الطلب كما وصل تماماً. إعادة تسلسل JSON قبل التحقق تغيّر البايتات فيفشل كل توقيع.
ابقَ متزامناً من دون Webhooks#
تصل Webhooks مرة على الأقل وقد تتأخر؛ ومرور ليلي على updated_at يسدّ أي فجوة:
import { syncOrders } from "@dukkan.one/app-sdk/commerce";
const result = await syncOrders(client, {
since: await loadHighWaterMark(installId),
onPage: async (orders) => { for (const order of orders) await upsert(order); },
});
await saveHighWaterMark(installId, result.highWaterMark);أنشئ طلباً#
ترسل orders.create (النطاق orders:create) الأسعار التي اتفقت عليها وتقرأ جدول الصرف في المتجر، فيكلّف عرض سعر بالدولار بالضبط ما تكلّفه السلة نفسها عند الدفع. أعد التحقق من الخيارات أولاً، ثم اشتقّ مفتاح التكرار من عرض السعر ليعيد المهمة المكررة الطلب المسجّل بدلاً من إنشاء طلب ثانٍ:
import { DukkanConflictError, DukkanValidationError } from "@dukkan.one/app-sdk";
import { buildCreateOrderLines, exchangeRateFor } from "@dukkan.one/app-sdk/commerce";
const store = await client.store.get();
const variants = await client.products.variants(quote.lines.map((line) => line.variantId).filter(Boolean));
const rate = exchangeRateFor(store, quote.currency); // null in the pricing currency, undefined when the store has no rate
if (rate === undefined) throw new Error(`store has no rate for ${quote.currency}`);
try {
const { data: order, replayed } = await client.orders.create({
currency: quote.currency,
items: buildCreateOrderLines(quote.lines, { from: store.pricing_currency.decimals, to: quote.decimals, rate }),
discount: { amount_minor: quote.discountMinor, title: quote.discountTitle },
shipping: { amount_minor: quote.shippingMinor, method: "delivery" },
customer: { name: quote.customerName, phone: quote.customerPhone, email: null },
shipping_address: { address: quote.address, province: quote.province, city: null, notes: null },
payment_method: "cod",
exchange_rate: rate,
note: quote.note,
tags: ["quote"],
attributes: { reference: { kind: "quote", number: quote.number, url: `https://offers.example.com/quotes/${quote.number}` } },
}, { idempotency: { ref: ["quote-accepted", quote.id] } });
await markConverted(quote.id, order.id, replayed);
} catch (error) {
if (error instanceof DukkanConflictError && error.code === "insufficient_stock") {
await askToRequote(quote.id, error.insufficientLines); // [{ variant_id, requested, available }]
} else if (error instanceof DukkanValidationError) {
log.warn("quote rejected", { fields: error.fieldPaths, requestId: error.requestId });
} else throw error;
}
await client.orders.update(order.id, { tags: { add: ["invoiced"] }, attributes: { invoice_id: invoice.id } }, {
idempotency: { ref: ["invoiced", invoice.id] },
});تجد products.search({ q }) عناصر الكتالوج بالاسم أو اسم الخيار أو SKU أو DSIN أو الباركود؛ وتقبل products.variants(ids) أي عدد من المعرّفات وتجلبها في دفعات من 100. وتحرّر orders.update (النطاق orders:write) الملاحظة والوسوم كعمليات مجموعات وخصائص تطبيقك وحده. العقد كاملاً في الطلبات التي تنشئها التطبيقات.
الخطوات التالية#
- الانتقال من HTTP اليدوي
- المبالغ بالوحدات الصغرى ومساعدات التجارة في المرجع
- العقد للغات الأخرى