@wocart/storefront 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +144 -2
- package/USAGE.md +1470 -0
- package/dist/abandoned-checkout.d.ts +22 -0
- package/dist/abandoned-checkout.js +89 -0
- package/dist/actions.d.ts +117 -0
- package/dist/actions.js +196 -0
- package/dist/astro.d.ts +6 -0
- package/dist/astro.js +3 -0
- package/dist/basket.d.ts +87 -0
- package/dist/basket.js +258 -0
- package/dist/client/cart.d.ts +30 -0
- package/dist/client/cart.js +26 -0
- package/dist/client/categories.d.ts +25 -0
- package/dist/client/categories.js +37 -0
- package/dist/client/config.d.ts +29 -0
- package/dist/client/config.js +41 -0
- package/dist/client/core.d.ts +88 -0
- package/dist/client/core.js +566 -0
- package/dist/client/errors.d.ts +19 -0
- package/dist/client/errors.js +31 -0
- package/dist/client/geo.d.ts +31 -0
- package/dist/client/geo.js +39 -0
- package/dist/client/index.d.ts +40 -0
- package/dist/client/index.js +81 -0
- package/dist/client/orders.d.ts +68 -0
- package/dist/client/orders.js +76 -0
- package/dist/client/page-data.d.ts +30 -0
- package/dist/client/page-data.js +36 -0
- package/dist/client/pages.d.ts +33 -0
- package/dist/client/pages.js +44 -0
- package/dist/client/paths.d.ts +8 -0
- package/dist/client/paths.js +10 -0
- package/dist/client/products.d.ts +56 -0
- package/dist/client/products.js +78 -0
- package/dist/client/shipping.d.ts +36 -0
- package/dist/client/shipping.js +47 -0
- package/dist/data/communes.d.ts +25 -0
- package/dist/data/communes.js +1690 -0
- package/dist/data/wilayas.d.ts +6 -0
- package/dist/data/wilayas.js +64 -0
- package/dist/domain.d.ts +341 -0
- package/dist/domain.js +214 -0
- package/dist/endpoints/proxy.d.ts +111 -0
- package/dist/endpoints/proxy.js +161 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +30 -0
- package/dist/locale-routing.d.ts +59 -0
- package/dist/locale-routing.js +62 -0
- package/dist/locales.d.ts +15 -0
- package/dist/locales.js +8 -0
- package/dist/meta-tracking.d.ts +34 -0
- package/dist/meta-tracking.js +72 -0
- package/dist/middleware.d.ts +100 -0
- package/dist/middleware.js +72 -0
- package/dist/offer-view.d.ts +10 -0
- package/dist/offer-view.js +13 -0
- package/dist/otp.d.ts +80 -0
- package/dist/otp.js +295 -0
- package/dist/phone.d.ts +2 -0
- package/dist/phone.js +34 -0
- package/dist/product-view.d.ts +12 -0
- package/dist/product-view.js +42 -0
- package/dist/routes.d.ts +45 -0
- package/dist/routes.js +59 -0
- package/dist/schemas.d.ts +753 -0
- package/dist/schemas.js +377 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +2 -0
- package/dist/types.d.ts +418 -0
- package/dist/types.js +1 -0
- package/dist/utils/image.d.ts +140 -0
- package/dist/utils/image.js +131 -0
- package/dist/utils/money.d.ts +39 -0
- package/dist/utils/money.js +43 -0
- package/dist/utils/product-image.d.ts +37 -0
- package/dist/utils/product-image.js +38 -0
- package/dist/whatsapp.d.ts +55 -0
- package/dist/whatsapp.js +89 -0
- package/package.json +76 -4
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep API Client - Core Infrastructure
|
|
3
|
+
*
|
|
4
|
+
* Hides: caching, request deduplication, retry logic, validation,
|
|
5
|
+
* header management, cancellation, and URL construction.
|
|
6
|
+
*
|
|
7
|
+
* Deep module principle: Maximum leverage for namespace implementations.
|
|
8
|
+
* One infrastructure layer serves all API methods.
|
|
9
|
+
*/
|
|
10
|
+
import { StorefrontError, isStorefrontError } from "./errors.js";
|
|
11
|
+
/**
|
|
12
|
+
* Result type for mutations (POST/PATCH).
|
|
13
|
+
*
|
|
14
|
+
* `domainError` is **always present on a failure the SDK produced** — every
|
|
15
|
+
* transport, HTTP, schema and abort path sets it, so a consumer branches on
|
|
16
|
+
* `result.domainError.category` without an optional-chain. It is declared as
|
|
17
|
+
* a required field on purpose: a new failure path that forgets it does not
|
|
18
|
+
* compile.
|
|
19
|
+
*
|
|
20
|
+
* `error` stays as the human-readable message (`domainError.message`), `code`
|
|
21
|
+
* stays an open string because the server's error codes are open (`HTTP_<status>`
|
|
22
|
+
* for gateway failures, the server's own code otherwise — a closed union would
|
|
23
|
+
* reject codes this SDK has not seen yet), and the index signature carries the
|
|
24
|
+
* server's flattened `context` (OTP's `windowSeconds`/`attemptsRemaining`/
|
|
25
|
+
* `terminal`, field issues) so a proxy can forward it without re-declaring each
|
|
26
|
+
* key. Read models do not come back as a `ClientResult` — reads degrade to
|
|
27
|
+
* `null`/`[]`/`{}` instead (see USAGE.md, "Failure and result contracts").
|
|
28
|
+
*/
|
|
29
|
+
let cacheGeneration = 0;
|
|
30
|
+
const ABORT_MESSAGE = "The request was cancelled";
|
|
31
|
+
const TIMEOUT_MESSAGE = "The request timed out";
|
|
32
|
+
const MALFORMED_MESSAGE = "The server response could not be read";
|
|
33
|
+
const SCHEMA_MESSAGE = "Schema validation failed";
|
|
34
|
+
/**
|
|
35
|
+
* The server's own error categories, translated to SDK categories. This is the
|
|
36
|
+
* wire vocabulary of the platform's ERROR_CATEGORIES, not a second copy of it:
|
|
37
|
+
* the server always wins when it names a category, and the status-code mapping
|
|
38
|
+
* below only covers failures that arrive without one (gateways, proxies).
|
|
39
|
+
*/
|
|
40
|
+
const WIRE_CATEGORIES = {
|
|
41
|
+
VALIDATION: "validation",
|
|
42
|
+
AUTHENTICATION: "authentication",
|
|
43
|
+
BUSINESS_LOGIC: "business",
|
|
44
|
+
SYSTEM: "system",
|
|
45
|
+
};
|
|
46
|
+
function isRecord(value) {
|
|
47
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
48
|
+
}
|
|
49
|
+
function readString(source, key) {
|
|
50
|
+
if (!isRecord(source))
|
|
51
|
+
return undefined;
|
|
52
|
+
const value = source[key];
|
|
53
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
54
|
+
}
|
|
55
|
+
function readNumber(source, key) {
|
|
56
|
+
if (!isRecord(source))
|
|
57
|
+
return undefined;
|
|
58
|
+
const value = source[key];
|
|
59
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
function categorizeFailure(status, wireCategory) {
|
|
62
|
+
if (typeof wireCategory === "string" && WIRE_CATEGORIES[wireCategory]) {
|
|
63
|
+
return WIRE_CATEGORIES[wireCategory];
|
|
64
|
+
}
|
|
65
|
+
if (status === 401 || status === 403)
|
|
66
|
+
return "authentication";
|
|
67
|
+
if (status === 400 || status === 422)
|
|
68
|
+
return "validation";
|
|
69
|
+
if (status === 404 || status === 409 || status === 429)
|
|
70
|
+
return "business";
|
|
71
|
+
if (status !== undefined && status >= 500)
|
|
72
|
+
return "system";
|
|
73
|
+
return "unknown";
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* What the platform actually did. A 2xx means it accepted the command, a 4xx is
|
|
77
|
+
* a clean refusal, and everything else — 5xx, dropped connections, timeouts —
|
|
78
|
+
* leaves the outcome unproven. Only a proven outcome may be replayed.
|
|
79
|
+
*/
|
|
80
|
+
function determineSideEffectOutcome(status) {
|
|
81
|
+
if (status === undefined)
|
|
82
|
+
return "unknown";
|
|
83
|
+
if (status >= 200 && status < 300)
|
|
84
|
+
return "committed";
|
|
85
|
+
if (status >= 400 && status < 500)
|
|
86
|
+
return "rejected";
|
|
87
|
+
return "unknown";
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A write is replayable only when the server refused it *and* said when to come
|
|
91
|
+
* back: retrying a dropped connection is how a shopper ends up with two orders.
|
|
92
|
+
* Reads are safe to repeat unless the request itself was rejected.
|
|
93
|
+
*/
|
|
94
|
+
function isReplayable(status, readOnly) {
|
|
95
|
+
if (readOnly)
|
|
96
|
+
return status === 429 || status >= 500;
|
|
97
|
+
return status === 429;
|
|
98
|
+
}
|
|
99
|
+
/** Field-level errors from the server's Zod failures (`context.fields`). */
|
|
100
|
+
function readFieldIssues(context) {
|
|
101
|
+
if (!isRecord(context) || !Array.isArray(context.fields))
|
|
102
|
+
return undefined;
|
|
103
|
+
const issues = context.fields.flatMap((entry) => {
|
|
104
|
+
if (!isRecord(entry))
|
|
105
|
+
return [];
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
field: typeof entry.path === "string" ? entry.path : "",
|
|
109
|
+
code: typeof entry.code === "string" ? entry.code : "INVALID",
|
|
110
|
+
message: typeof entry.message === "string" ? entry.message : "Invalid value",
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
});
|
|
114
|
+
return issues.length > 0 ? issues : undefined;
|
|
115
|
+
}
|
|
116
|
+
function composeSignal(signal, timeoutMs) {
|
|
117
|
+
if (!signal && timeoutMs === undefined) {
|
|
118
|
+
return { signal: undefined, aborted: () => false, timedOut: () => false, cleanup: () => { } };
|
|
119
|
+
}
|
|
120
|
+
if (timeoutMs === undefined) {
|
|
121
|
+
return { signal, aborted: () => signal.aborted, timedOut: () => false, cleanup: () => { } };
|
|
122
|
+
}
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
let timedOut = false;
|
|
125
|
+
const timer = setTimeout(() => {
|
|
126
|
+
timedOut = true;
|
|
127
|
+
controller.abort();
|
|
128
|
+
}, timeoutMs);
|
|
129
|
+
const forward = () => controller.abort();
|
|
130
|
+
if (signal) {
|
|
131
|
+
if (signal.aborted)
|
|
132
|
+
forward();
|
|
133
|
+
else
|
|
134
|
+
signal.addEventListener("abort", forward, { once: true });
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
aborted: () => signal?.aborted === true,
|
|
139
|
+
timedOut: () => timedOut,
|
|
140
|
+
cleanup: () => {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
signal?.removeEventListener("abort", forward);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function abortedError(operation) {
|
|
147
|
+
return {
|
|
148
|
+
category: "transport",
|
|
149
|
+
code: "REQUEST_ABORTED",
|
|
150
|
+
message: ABORT_MESSAGE,
|
|
151
|
+
retryable: false,
|
|
152
|
+
operation,
|
|
153
|
+
sideEffect: "not_started",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A failure that happened before the server answered — or after it stopped
|
|
158
|
+
* answering. `readOnly` marks reads: only a read may be replayed on one of
|
|
159
|
+
* these, because a write's outcome is unproven and replaying it is how a
|
|
160
|
+
* shopper ends up with two orders.
|
|
161
|
+
*/
|
|
162
|
+
function transportFailure(cause, signal, operation, readOnly) {
|
|
163
|
+
const sideEffect = readOnly ? "not_started" : "unknown";
|
|
164
|
+
if (signal.timedOut()) {
|
|
165
|
+
return {
|
|
166
|
+
category: "transport",
|
|
167
|
+
code: "REQUEST_TIMEOUT",
|
|
168
|
+
message: TIMEOUT_MESSAGE,
|
|
169
|
+
retryable: readOnly,
|
|
170
|
+
operation,
|
|
171
|
+
sideEffect,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (signal.aborted()) {
|
|
175
|
+
return { ...abortedError(operation), sideEffect };
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
category: "transport",
|
|
179
|
+
code: "NETWORK_ERROR",
|
|
180
|
+
message: cause instanceof Error ? cause.message : "Network error",
|
|
181
|
+
retryable: readOnly,
|
|
182
|
+
operation,
|
|
183
|
+
sideEffect,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* A 2xx response the SDK could not read or validate. The server already
|
|
188
|
+
* committed the command — only the answer went missing — so a write must not be
|
|
189
|
+
* replayed on the strength of it.
|
|
190
|
+
*/
|
|
191
|
+
function unreadableSuccess(code, message, operation, readOnly) {
|
|
192
|
+
return {
|
|
193
|
+
category: "system",
|
|
194
|
+
code,
|
|
195
|
+
message,
|
|
196
|
+
retryable: readOnly,
|
|
197
|
+
operation,
|
|
198
|
+
sideEffect: readOnly ? "not_started" : "committed",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A failure the server reported itself: its message, its code, its category, and
|
|
203
|
+
* every diagnostic it chose to publish in `context`.
|
|
204
|
+
*/
|
|
205
|
+
function serverFailure(status, body, operation, readOnly) {
|
|
206
|
+
const context = isRecord(body) ? body.context : undefined;
|
|
207
|
+
const message = readString(body, "error") ??
|
|
208
|
+
readString(body, "message") ??
|
|
209
|
+
(status !== undefined ? `HTTP ${status}` : "Request failed");
|
|
210
|
+
const code = readString(body, "code");
|
|
211
|
+
const retryAfterSeconds = readNumber(context, "windowSeconds") ?? readNumber(body, "retryAfterSeconds");
|
|
212
|
+
const fieldIssues = readFieldIssues(context);
|
|
213
|
+
const requestId = readString(context, "requestId");
|
|
214
|
+
const domainError = {
|
|
215
|
+
category: categorizeFailure(status, isRecord(body) ? body.category : undefined),
|
|
216
|
+
code: code ?? (status !== undefined ? `HTTP_${status}` : "HTTP_ERROR"),
|
|
217
|
+
message,
|
|
218
|
+
retryable: isReplayable(status, readOnly),
|
|
219
|
+
operation,
|
|
220
|
+
sideEffect: determineSideEffectOutcome(status),
|
|
221
|
+
...(fieldIssues !== undefined ? { fieldIssues } : {}),
|
|
222
|
+
...(requestId !== undefined ? { requestId } : {}),
|
|
223
|
+
...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
message,
|
|
227
|
+
code,
|
|
228
|
+
flatContext: isRecord(context) ? context : undefined,
|
|
229
|
+
domainError,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
async function readBody(response) {
|
|
233
|
+
try {
|
|
234
|
+
return { ok: true, value: await response.json() };
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return { ok: false };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** How long a read may take, retries included, unless the client sets another limit. */
|
|
241
|
+
export const DEFAULT_READ_TIMEOUT_MS = 10_000;
|
|
242
|
+
/** A positive limit is used as given; `0` means no limit; anything else falls back to the default. */
|
|
243
|
+
function resolveReadTimeout(value) {
|
|
244
|
+
if (value === 0)
|
|
245
|
+
return undefined;
|
|
246
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : DEFAULT_READ_TIMEOUT_MS;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Create client core infrastructure
|
|
250
|
+
*
|
|
251
|
+
* @param config - Client configuration
|
|
252
|
+
* @returns Core infrastructure for namespace implementations
|
|
253
|
+
*/
|
|
254
|
+
export function createClientCore(config) {
|
|
255
|
+
const { serverUrl, apiKey } = config;
|
|
256
|
+
const readTimeoutMs = resolveReadTimeout(config.readTimeoutMs);
|
|
257
|
+
// Finished values only, never a pending request. A client may outlive the
|
|
258
|
+
// request that made it (one per isolate); a request that waits on another
|
|
259
|
+
// request's pending read never settles on Cloudflare Workers once that other
|
|
260
|
+
// request is cancelled, and every later caller would wait behind it.
|
|
261
|
+
const valueCache = new Map();
|
|
262
|
+
let observedCacheGeneration = cacheGeneration;
|
|
263
|
+
function resetClearedCaches() {
|
|
264
|
+
if (observedCacheGeneration === cacheGeneration)
|
|
265
|
+
return;
|
|
266
|
+
valueCache.clear();
|
|
267
|
+
observedCacheGeneration = cacheGeneration;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Build full URL from path
|
|
271
|
+
*/
|
|
272
|
+
function buildUrl(path) {
|
|
273
|
+
const base = serverUrl.replace(/\/$/, "");
|
|
274
|
+
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
275
|
+
return `${base}${cleanPath}`;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Build request headers
|
|
279
|
+
*/
|
|
280
|
+
function buildHeaders(customHeaders) {
|
|
281
|
+
return {
|
|
282
|
+
"X-Store-API-Key": apiKey,
|
|
283
|
+
"Content-Type": "application/json",
|
|
284
|
+
...customHeaders,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Execute fetch with retry logic.
|
|
289
|
+
*
|
|
290
|
+
* Retries are for reads only — callers pass `retries: 0` for anything that
|
|
291
|
+
* writes. An aborted signal is never retried and never worth waiting on.
|
|
292
|
+
*/
|
|
293
|
+
async function fetchWithRetry(url, options, retries, signal) {
|
|
294
|
+
let lastError = null;
|
|
295
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
296
|
+
if (signal?.aborted) {
|
|
297
|
+
throw new Error(ABORT_MESSAGE);
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
return await fetch(url, options);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
lastError = error instanceof Error ? error : new Error("Network error");
|
|
304
|
+
if (signal?.aborted || attempt === retries) {
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
// Exponential backoff: 100ms, 200ms
|
|
308
|
+
const delay = 100 * Math.pow(2, attempt);
|
|
309
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
throw lastError ?? new Error("Network error");
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* GET request with opt-in caching.
|
|
316
|
+
*
|
|
317
|
+
* Every call makes its own request: a call never waits on another call's
|
|
318
|
+
* pending request, so no caller can be held by work that belongs to someone
|
|
319
|
+
* else. Pass `{ cache: true }` for read-mostly data (config, categories,
|
|
320
|
+
* geo): the finished value is kept and later calls reuse it.
|
|
321
|
+
*
|
|
322
|
+
* Rejected and nullish results are never cached — a transient failure
|
|
323
|
+
* or an absent record must not be pinned for the isolate's lifetime.
|
|
324
|
+
*
|
|
325
|
+
* A read is bounded by the client's `readTimeoutMs` (10 seconds unless the
|
|
326
|
+
* client sets another), retries included, so a server that stops answering
|
|
327
|
+
* degrades the read instead of holding the page.
|
|
328
|
+
*
|
|
329
|
+
* Pass `{ envelope: true }` for endpoints that carry metadata outside
|
|
330
|
+
* `data` (e.g. pagination totals) and need the full response body.
|
|
331
|
+
*
|
|
332
|
+
* A failing read never resolves to a wrong value: it throws a
|
|
333
|
+
* `StorefrontError` carrying the same `DomainError` the mutation path
|
|
334
|
+
* returns. A read the caller cancels never retries and is never cached.
|
|
335
|
+
*/
|
|
336
|
+
async function get(path, schema, opts) {
|
|
337
|
+
resetClearedCaches();
|
|
338
|
+
const url = buildUrl(path);
|
|
339
|
+
const operation = opts?.operation ?? `GET ${path}`;
|
|
340
|
+
const request = composeSignal(opts?.signal, opts?.timeoutMs ?? readTimeoutMs);
|
|
341
|
+
// A caller-supplied signal opts the read out of retries and the cache: its
|
|
342
|
+
// abort is the caller's decision, and an abandoned read must not be kept.
|
|
343
|
+
const callerCancels = opts?.signal !== undefined;
|
|
344
|
+
const cacheable = opts?.cache === true && !callerCancels;
|
|
345
|
+
try {
|
|
346
|
+
if (cacheable && valueCache.has(url)) {
|
|
347
|
+
return valueCache.get(url);
|
|
348
|
+
}
|
|
349
|
+
const value = await (async () => {
|
|
350
|
+
const response = await fetchWithRetry(url, {
|
|
351
|
+
method: "GET",
|
|
352
|
+
headers: buildHeaders(),
|
|
353
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
354
|
+
}, callerCancels ? 0 : 2, request.signal);
|
|
355
|
+
if (!response.ok) {
|
|
356
|
+
const body = await readBody(response);
|
|
357
|
+
throw new StorefrontError(serverFailure(response.status, body.ok ? body.value : undefined, operation, true)
|
|
358
|
+
.domainError);
|
|
359
|
+
}
|
|
360
|
+
const body = await readBody(response);
|
|
361
|
+
if (!body.ok) {
|
|
362
|
+
throw new StorefrontError(unreadableSuccess("MALFORMED_RESPONSE", MALFORMED_MESSAGE, operation, true));
|
|
363
|
+
}
|
|
364
|
+
const json = body.value;
|
|
365
|
+
if (opts?.envelope) {
|
|
366
|
+
if (schema) {
|
|
367
|
+
const parsed = schema.safeParse(json);
|
|
368
|
+
if (!parsed.success) {
|
|
369
|
+
throw new StorefrontError(unreadableSuccess("SCHEMA_VALIDATION_FAILED", SCHEMA_MESSAGE, operation, true));
|
|
370
|
+
}
|
|
371
|
+
return parsed.data;
|
|
372
|
+
}
|
|
373
|
+
return json;
|
|
374
|
+
}
|
|
375
|
+
const data = isRecord(json) ? json.data : undefined;
|
|
376
|
+
// Validate with schema if provided
|
|
377
|
+
if (schema) {
|
|
378
|
+
const parsed = schema.safeParse(data);
|
|
379
|
+
if (!parsed.success) {
|
|
380
|
+
throw new StorefrontError(unreadableSuccess("SCHEMA_VALIDATION_FAILED", SCHEMA_MESSAGE, operation, true));
|
|
381
|
+
}
|
|
382
|
+
return parsed.data;
|
|
383
|
+
}
|
|
384
|
+
return data;
|
|
385
|
+
})();
|
|
386
|
+
if (cacheable && value != null)
|
|
387
|
+
valueCache.set(url, value);
|
|
388
|
+
return value;
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
if (error instanceof StorefrontError)
|
|
392
|
+
throw error;
|
|
393
|
+
throw new StorefrontError(transportFailure(error, request, operation, true));
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
request.cleanup();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* POST request with error handling.
|
|
401
|
+
*
|
|
402
|
+
* By default the `{ data: ... }` envelope is unwrapped and `data`
|
|
403
|
+
* returned. Pass `topLevelData: true` for endpoints whose payload sits at
|
|
404
|
+
* the top level (e.g. `POST /store/abandoned` → `{ success, id }`).
|
|
405
|
+
*
|
|
406
|
+
* On failure the error result carries `code`, every field of the server's
|
|
407
|
+
* `context` object (`windowSeconds`, `attemptsRemaining`, `terminal`)
|
|
408
|
+
* flattened into the result — the checkout UI reads these — and the whole
|
|
409
|
+
* failure again as `domainError`, the serializable contract a caller may
|
|
410
|
+
* branch on. A POST never throws: an aborted or dropped request leaves the
|
|
411
|
+
* outcome `unknown`, never a retry of a possible duplicate.
|
|
412
|
+
*/
|
|
413
|
+
async function post(path, body, opts) {
|
|
414
|
+
const url = buildUrl(path);
|
|
415
|
+
const operation = opts?.operation ?? `POST ${path}`;
|
|
416
|
+
const request = composeSignal(opts?.signal, opts?.timeoutMs);
|
|
417
|
+
if (request.aborted()) {
|
|
418
|
+
request.cleanup();
|
|
419
|
+
const domainError = abortedError(operation);
|
|
420
|
+
return { success: false, error: domainError.message, domainError };
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
const response = await fetchWithRetry(url, {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: buildHeaders(opts?.headers),
|
|
426
|
+
body: JSON.stringify(body),
|
|
427
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
428
|
+
}, 0, request.signal);
|
|
429
|
+
const read = await readBody(response);
|
|
430
|
+
if (!response.ok) {
|
|
431
|
+
const failure = serverFailure(response.status, read.ok ? read.value : undefined, operation, false);
|
|
432
|
+
return {
|
|
433
|
+
success: false,
|
|
434
|
+
error: failure.message,
|
|
435
|
+
...(failure.code !== undefined ? { code: failure.code } : {}),
|
|
436
|
+
domainError: failure.domainError,
|
|
437
|
+
...failure.flatContext,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (!read.ok || !isRecord(read.value)) {
|
|
441
|
+
const domainError = unreadableSuccess("MALFORMED_RESPONSE", MALFORMED_MESSAGE, operation, false);
|
|
442
|
+
return { success: false, error: domainError.message, domainError };
|
|
443
|
+
}
|
|
444
|
+
const payload = read.value;
|
|
445
|
+
const data = opts?.topLevelData ? payload : payload.data;
|
|
446
|
+
// Validate with schema if provided
|
|
447
|
+
if (opts?.schema) {
|
|
448
|
+
const parsed = opts.schema.safeParse(data);
|
|
449
|
+
if (!parsed.success) {
|
|
450
|
+
const domainError = unreadableSuccess("SCHEMA_VALIDATION_FAILED", SCHEMA_MESSAGE, operation, false);
|
|
451
|
+
return { success: false, error: domainError.message, domainError };
|
|
452
|
+
}
|
|
453
|
+
return { success: true, data: parsed.data };
|
|
454
|
+
}
|
|
455
|
+
return { success: true, data: data };
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
const domainError = transportFailure(error, request, operation, false);
|
|
459
|
+
return { success: false, error: domainError.message, domainError };
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
request.cleanup();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* PATCH request with error handling
|
|
467
|
+
*/
|
|
468
|
+
async function patch(path, body, optsOrSchema) {
|
|
469
|
+
const url = buildUrl(path);
|
|
470
|
+
const opts = optsOrSchema && "safeParse" in optsOrSchema
|
|
471
|
+
? { schema: optsOrSchema }
|
|
472
|
+
: (optsOrSchema ?? {});
|
|
473
|
+
const operation = opts.operation ?? `PATCH ${path}`;
|
|
474
|
+
const request = composeSignal(opts.signal, opts.timeoutMs);
|
|
475
|
+
if (request.aborted()) {
|
|
476
|
+
request.cleanup();
|
|
477
|
+
const domainError = abortedError(operation);
|
|
478
|
+
return { success: false, error: domainError.message, domainError };
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
const response = await fetchWithRetry(url, {
|
|
482
|
+
method: "PATCH",
|
|
483
|
+
headers: buildHeaders(opts.headers),
|
|
484
|
+
body: JSON.stringify(body),
|
|
485
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
486
|
+
}, 0, request.signal);
|
|
487
|
+
const read = await readBody(response);
|
|
488
|
+
if (!response.ok) {
|
|
489
|
+
const failure = serverFailure(response.status, read.ok ? read.value : undefined, operation, false);
|
|
490
|
+
return {
|
|
491
|
+
success: false,
|
|
492
|
+
error: failure.message,
|
|
493
|
+
...(failure.code !== undefined ? { code: failure.code } : {}),
|
|
494
|
+
domainError: failure.domainError,
|
|
495
|
+
...failure.flatContext,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (!read.ok || !isRecord(read.value)) {
|
|
499
|
+
const domainError = unreadableSuccess("MALFORMED_RESPONSE", MALFORMED_MESSAGE, operation, false);
|
|
500
|
+
return { success: false, error: domainError.message, domainError };
|
|
501
|
+
}
|
|
502
|
+
const data = read.value.data;
|
|
503
|
+
// Validate with schema if provided
|
|
504
|
+
if (opts.schema) {
|
|
505
|
+
const parsed = opts.schema.safeParse(data);
|
|
506
|
+
if (!parsed.success) {
|
|
507
|
+
const domainError = unreadableSuccess("SCHEMA_VALIDATION_FAILED", SCHEMA_MESSAGE, operation, false);
|
|
508
|
+
return { success: false, error: domainError.message, domainError };
|
|
509
|
+
}
|
|
510
|
+
return { success: true, data: parsed.data };
|
|
511
|
+
}
|
|
512
|
+
return { success: true, data: data };
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
const domainError = transportFailure(error, request, operation, false);
|
|
516
|
+
return { success: false, error: domainError.message, domainError };
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
request.cleanup();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
config,
|
|
524
|
+
get,
|
|
525
|
+
post,
|
|
526
|
+
patch,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Report a read that a namespace swallowed.
|
|
531
|
+
*
|
|
532
|
+
* Namespaces degrade instead of throwing — that is their contract — so this
|
|
533
|
+
* is the operator's only signal that a call did not complete. A failure the
|
|
534
|
+
* core produced already carries its DomainError; anything else (a bug inside
|
|
535
|
+
* a namespace, a storage exception) is normalised to the same shape, so a
|
|
536
|
+
* consumer's handler branches on one contract. A throwing hook never turns a
|
|
537
|
+
* degraded read into a thrown one.
|
|
538
|
+
*/
|
|
539
|
+
export function reportDegradedRead(config, error, operation) {
|
|
540
|
+
const onError = config?.onError;
|
|
541
|
+
if (!onError)
|
|
542
|
+
return;
|
|
543
|
+
const domainError = isStorefrontError(error)
|
|
544
|
+
? error.domainError
|
|
545
|
+
: {
|
|
546
|
+
category: "transport",
|
|
547
|
+
code: "NETWORK_ERROR",
|
|
548
|
+
message: error instanceof Error ? error.message : "Network error",
|
|
549
|
+
retryable: true,
|
|
550
|
+
operation,
|
|
551
|
+
sideEffect: "not_started",
|
|
552
|
+
};
|
|
553
|
+
try {
|
|
554
|
+
onError(domainError);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// Observability must never escalate a degraded read.
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Clear the request cache
|
|
562
|
+
* Useful for testing or forcing fresh data
|
|
563
|
+
*/
|
|
564
|
+
export function clearCache() {
|
|
565
|
+
cacheGeneration += 1;
|
|
566
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK error type for read failures.
|
|
3
|
+
*
|
|
4
|
+
* Reads resolve their "absent" outcomes (null, [], {}) through the namespaces;
|
|
5
|
+
* everything else is a real failure and arrives as one of these. It stays an
|
|
6
|
+
* `Error` so existing `catch`/`instanceof Error` call sites keep working, and it
|
|
7
|
+
* carries a plain, serializable `DomainError` so a caller can branch on
|
|
8
|
+
* category, code, retryability and side-effect outcome instead of a string.
|
|
9
|
+
*/
|
|
10
|
+
import type { DomainError } from "../domain.js";
|
|
11
|
+
export declare class StorefrontError extends Error {
|
|
12
|
+
readonly domainError: DomainError;
|
|
13
|
+
constructor(domainError: DomainError);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Recognises a StorefrontError across a realm boundary (worker, iframe, Astro
|
|
17
|
+
* action), where `instanceof` fails but the serialized shape survives.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isStorefrontError(value: unknown): value is StorefrontError;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK error type for read failures.
|
|
3
|
+
*
|
|
4
|
+
* Reads resolve their "absent" outcomes (null, [], {}) through the namespaces;
|
|
5
|
+
* everything else is a real failure and arrives as one of these. It stays an
|
|
6
|
+
* `Error` so existing `catch`/`instanceof Error` call sites keep working, and it
|
|
7
|
+
* carries a plain, serializable `DomainError` so a caller can branch on
|
|
8
|
+
* category, code, retryability and side-effect outcome instead of a string.
|
|
9
|
+
*/
|
|
10
|
+
export class StorefrontError extends Error {
|
|
11
|
+
domainError;
|
|
12
|
+
constructor(domainError) {
|
|
13
|
+
super(domainError.message);
|
|
14
|
+
this.name = "StorefrontError";
|
|
15
|
+
this.domainError = domainError;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Recognises a StorefrontError across a realm boundary (worker, iframe, Astro
|
|
20
|
+
* action), where `instanceof` fails but the serialized shape survives.
|
|
21
|
+
*/
|
|
22
|
+
export function isStorefrontError(value) {
|
|
23
|
+
if (value instanceof StorefrontError)
|
|
24
|
+
return true;
|
|
25
|
+
if (typeof value !== "object" || value === null)
|
|
26
|
+
return false;
|
|
27
|
+
const candidate = value;
|
|
28
|
+
return (candidate.name === "StorefrontError" &&
|
|
29
|
+
typeof candidate.domainError === "object" &&
|
|
30
|
+
candidate.domainError !== null);
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geo Namespace - Wilayas & Communes
|
|
3
|
+
*
|
|
4
|
+
* Deep module: hides where the wilaya dataset lives and the communes lookup
|
|
5
|
+
* behind two calls.
|
|
6
|
+
*
|
|
7
|
+
* `wilayas()` resolves the bundled static dataset — Algeria's 58 wilayas
|
|
8
|
+
* are stable reference data, so no network round-trip. It stays async so the
|
|
9
|
+
* data can move server-side without touching call sites.
|
|
10
|
+
*
|
|
11
|
+
* `communes()` reads the server and is cached per wilaya (reference data);
|
|
12
|
+
* failures resolve to `[]` — never throw. Wilaya IDs are 1–58; anything
|
|
13
|
+
* else is a 400 upstream, surfaced as `[]` (no communes to show).
|
|
14
|
+
*/
|
|
15
|
+
import { type ClientCore } from "./core.js";
|
|
16
|
+
import type { Commune, Wilaya } from "../types.js";
|
|
17
|
+
export interface GeoNamespace {
|
|
18
|
+
/**
|
|
19
|
+
* Algeria's 58 wilayas, with Arabic names for RTL checkout forms.
|
|
20
|
+
*/
|
|
21
|
+
wilayas(): Promise<Wilaya[]>;
|
|
22
|
+
/**
|
|
23
|
+
* Communes for one wilaya (1–58), cached. Resolves to `[]` on failure
|
|
24
|
+
* or an unknown wilaya.
|
|
25
|
+
*/
|
|
26
|
+
communes(wilayaId: number | string): Promise<Commune[]>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Create geo namespace
|
|
30
|
+
*/
|
|
31
|
+
export declare function createGeoNamespace(core: ClientCore): GeoNamespace;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geo Namespace - Wilayas & Communes
|
|
3
|
+
*
|
|
4
|
+
* Deep module: hides where the wilaya dataset lives and the communes lookup
|
|
5
|
+
* behind two calls.
|
|
6
|
+
*
|
|
7
|
+
* `wilayas()` resolves the bundled static dataset — Algeria's 58 wilayas
|
|
8
|
+
* are stable reference data, so no network round-trip. It stays async so the
|
|
9
|
+
* data can move server-side without touching call sites.
|
|
10
|
+
*
|
|
11
|
+
* `communes()` reads the server and is cached per wilaya (reference data);
|
|
12
|
+
* failures resolve to `[]` — never throw. Wilaya IDs are 1–58; anything
|
|
13
|
+
* else is a 400 upstream, surfaced as `[]` (no communes to show).
|
|
14
|
+
*/
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { CommuneSchema } from "../schemas.js";
|
|
17
|
+
import { STOREFRONT_OPERATIONS } from "../domain.js";
|
|
18
|
+
import { reportDegradedRead } from "./core.js";
|
|
19
|
+
import { WILAYAS } from "../data/wilayas.js";
|
|
20
|
+
/**
|
|
21
|
+
* Create geo namespace
|
|
22
|
+
*/
|
|
23
|
+
export function createGeoNamespace(core) {
|
|
24
|
+
return {
|
|
25
|
+
async wilayas() {
|
|
26
|
+
return WILAYAS;
|
|
27
|
+
},
|
|
28
|
+
async communes(wilayaId) {
|
|
29
|
+
try {
|
|
30
|
+
const communes = await core.get(`/store/communes/${encodeURIComponent(String(wilayaId))}`, z.array(CommuneSchema).nullable(), { cache: true, operation: STOREFRONT_OPERATIONS.geoCommunes });
|
|
31
|
+
return communes ?? [];
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
reportDegradedRead(core.config, error, "geo.communes");
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|