@wocart/storefront 1.0.0 → 1.2.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.
@@ -237,6 +237,14 @@ async function readBody(response) {
237
237
  return { ok: false };
238
238
  }
239
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
+ }
240
248
  /**
241
249
  * Create client core infrastructure
242
250
  *
@@ -245,14 +253,17 @@ async function readBody(response) {
245
253
  */
246
254
  export function createClientCore(config) {
247
255
  const { serverUrl, apiKey } = config;
248
- const requestCache = new Map();
249
- const inflightRequests = new Map();
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();
250
262
  let observedCacheGeneration = cacheGeneration;
251
263
  function resetClearedCaches() {
252
264
  if (observedCacheGeneration === cacheGeneration)
253
265
  return;
254
- requestCache.clear();
255
- inflightRequests.clear();
266
+ valueCache.clear();
256
267
  observedCacheGeneration = cacheGeneration;
257
268
  }
258
269
  /**
@@ -301,107 +312,80 @@ export function createClientCore(config) {
301
312
  throw lastError ?? new Error("Network error");
302
313
  }
303
314
  /**
304
- * GET request with in-flight deduplication and opt-in caching.
315
+ * GET request with opt-in caching.
305
316
  *
306
- * Default behaviour: every call fetches fresh,
307
- * but parallel identical calls coalesce into one request. Pass
308
- * `{ cache: true }` for read-mostly data (config, categories, geo).
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.
309
321
  *
310
322
  * Rejected and nullish results are never cached — a transient failure
311
323
  * or an absent record must not be pinned for the isolate's lifetime.
312
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
+ *
313
329
  * Pass `{ envelope: true }` for endpoints that carry metadata outside
314
330
  * `data` (e.g. pagination totals) and need the full response body.
315
331
  *
316
332
  * A failing read never resolves to a wrong value: it throws a
317
333
  * `StorefrontError` carrying the same `DomainError` the mutation path
318
- * returns. A cancelled or timed-out read neither retries nor coalesces —
319
- * one caller's abort must not cancel another's request, and an abandoned
320
- * request must not be pinned in the cache.
334
+ * returns. A read the caller cancels never retries and is never cached.
321
335
  */
322
336
  async function get(path, schema, opts) {
323
337
  resetClearedCaches();
324
338
  const url = buildUrl(path);
325
339
  const operation = opts?.operation ?? `GET ${path}`;
326
- const request = composeSignal(opts?.signal, opts?.timeoutMs);
327
- const shared = request.signal === undefined;
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;
328
345
  try {
329
- // Check cache first (only successful results ever live here)
330
- if (opts?.cache && shared) {
331
- const cached = requestCache.get(url);
332
- if (cached) {
333
- return (await cached);
334
- }
346
+ if (cacheable && valueCache.has(url)) {
347
+ return valueCache.get(url);
335
348
  }
336
- // Check in-flight requests for deduplication
337
- if (shared) {
338
- const inflight = inflightRequests.get(url);
339
- if (inflight) {
340
- return (await inflight);
341
- }
342
- }
343
- // Create new request
344
- const requestPromise = (async () => {
345
- try {
346
- const response = await fetchWithRetry(url, {
347
- method: "GET",
348
- headers: buildHeaders(),
349
- ...(request.signal ? { signal: request.signal } : {}),
350
- }, shared ? 2 : 0, request.signal);
351
- if (!response.ok) {
352
- const body = await readBody(response);
353
- throw new StorefrontError(serverFailure(response.status, body.ok ? body.value : undefined, operation, true)
354
- .domainError);
355
- }
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
356
  const body = await readBody(response);
357
- if (!body.ok) {
358
- throw new StorefrontError(unreadableSuccess("MALFORMED_RESPONSE", MALFORMED_MESSAGE, operation, true));
359
- }
360
- const json = body.value;
361
- if (opts?.envelope) {
362
- if (schema) {
363
- const parsed = schema.safeParse(json);
364
- if (!parsed.success) {
365
- throw new StorefrontError(unreadableSuccess("SCHEMA_VALIDATION_FAILED", SCHEMA_MESSAGE, operation, true));
366
- }
367
- return parsed.data;
368
- }
369
- return json;
370
- }
371
- const data = isRecord(json) ? json.data : undefined;
372
- // Validate with schema if provided
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) {
373
366
  if (schema) {
374
- const parsed = schema.safeParse(data);
367
+ const parsed = schema.safeParse(json);
375
368
  if (!parsed.success) {
376
369
  throw new StorefrontError(unreadableSuccess("SCHEMA_VALIDATION_FAILED", SCHEMA_MESSAGE, operation, true));
377
370
  }
378
371
  return parsed.data;
379
372
  }
380
- return data;
373
+ return json;
381
374
  }
382
- finally {
383
- // Remove from inflight after completion
384
- if (shared) {
385
- inflightRequests.delete(url);
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));
386
381
  }
382
+ return parsed.data;
387
383
  }
384
+ return data;
388
385
  })();
389
- // Store in-flight so parallel callers coalesce (only when no individual signal)
390
- if (shared) {
391
- inflightRequests.set(url, requestPromise);
392
- }
393
- if (opts?.cache && shared) {
394
- requestCache.set(url, requestPromise);
395
- // A failed or empty lookup must not be pinned: evict so the next
396
- // call retries against the server.
397
- requestPromise.then((value) => {
398
- if (value == null)
399
- requestCache.delete(url);
400
- }, () => {
401
- requestCache.delete(url);
402
- });
403
- }
404
- return await requestPromise;
386
+ if (cacheable && value != null)
387
+ valueCache.set(url, value);
388
+ return value;
405
389
  }
406
390
  catch (error) {
407
391
  if (error instanceof StorefrontError)
@@ -446,11 +430,11 @@ export function createClientCore(config) {
446
430
  if (!response.ok) {
447
431
  const failure = serverFailure(response.status, read.ok ? read.value : undefined, operation, false);
448
432
  return {
433
+ ...failure.flatContext,
449
434
  success: false,
450
435
  error: failure.message,
451
436
  ...(failure.code !== undefined ? { code: failure.code } : {}),
452
437
  domainError: failure.domainError,
453
- ...failure.flatContext,
454
438
  };
455
439
  }
456
440
  if (!read.ok || !isRecord(read.value)) {
@@ -504,11 +488,11 @@ export function createClientCore(config) {
504
488
  if (!response.ok) {
505
489
  const failure = serverFailure(response.status, read.ok ? read.value : undefined, operation, false);
506
490
  return {
491
+ ...failure.flatContext,
507
492
  success: false,
508
493
  error: failure.message,
509
494
  ...(failure.code !== undefined ? { code: failure.code } : {}),
510
495
  domainError: failure.domainError,
511
- ...failure.flatContext,
512
496
  };
513
497
  }
514
498
  if (!read.ok || !isRecord(read.value)) {
@@ -542,6 +526,16 @@ export function createClientCore(config) {
542
526
  patch,
543
527
  };
544
528
  }
529
+ /**
530
+ * The server's own answer that a looked-up record does not exist
531
+ * (`PRODUCT_NOT_FOUND`, `STORE_PAGE_NOT_FOUND`, …). A lookup returns `null` for
532
+ * it without reporting a failure: a stale link is not an outage. A 404 that
533
+ * does not carry the server's code — a wrong `serverUrl`, a route the platform
534
+ * does not have — is not absence, and is still reported.
535
+ */
536
+ export function isNotFoundAnswer(error) {
537
+ return isStorefrontError(error) && /_NOT_FOUND$/.test(error.domainError.code);
538
+ }
545
539
  /**
546
540
  * Report a read that a namespace swallowed.
547
541
  *
@@ -5,9 +5,10 @@
5
5
  * shared core: config, products, categories, pages, shipping, geo, cart,
6
6
  * orders. The core is internal — consumers see only namespaces.
7
7
  *
8
- * One core means one connection story for every namespace: identical
9
- * parallel GETs coalesce into a single upstream request, and the opt-in
10
- * caches (config, categories, communes) are shared per client instance.
8
+ * One core means one connection story for every namespace: every read is
9
+ * bounded by the client's `readTimeoutMs`, each call makes its own request
10
+ * (none waits on another's pending one), and the opt-in caches (config,
11
+ * categories, communes) keep finished results per client instance.
11
12
  * A theme server render typically builds one client per request.
12
13
  *
13
14
  * ```ts
@@ -5,9 +5,10 @@
5
5
  * shared core: config, products, categories, pages, shipping, geo, cart,
6
6
  * orders. The core is internal — consumers see only namespaces.
7
7
  *
8
- * One core means one connection story for every namespace: identical
9
- * parallel GETs coalesce into a single upstream request, and the opt-in
10
- * caches (config, categories, communes) are shared per client instance.
8
+ * One core means one connection story for every namespace: every read is
9
+ * bounded by the client's `readTimeoutMs`, each call makes its own request
10
+ * (none waits on another's pending one), and the opt-in caches (config,
11
+ * categories, communes) keep finished results per client instance.
11
12
  * A theme server render typically builds one client per request.
12
13
  *
13
14
  * ```ts
@@ -34,14 +34,17 @@ export interface OtpNamespace {
34
34
  * Send a WhatsApp verification code. On "unavailable" the server returns
35
35
  * a signed bypassToken — checkout proceeds unverified (fail-open).
36
36
  * Rate-limit errors carry `code: "OTP_RATE_LIMITED"` and `windowSeconds`.
37
+ * `forwardedHeaders` carries the shopper's address: the server's send and
38
+ * verify limits count per shopper, and without it every shopper of the
39
+ * store shares the one limit of this worker.
37
40
  */
38
- send(phone: string): Promise<ClientResult<OtpSendResult>>;
41
+ send(phone: string, forwardedHeaders?: Record<string, string>): Promise<ClientResult<OtpSendResult>>;
39
42
  /**
40
43
  * Verify the code the customer typed. Wrong-but-retryable codes return
41
44
  * `code`/`attemptsRemaining`/`terminal` on the error result; a success
42
45
  * returns the signed `otpToken` that travels with the order.
43
46
  */
44
- verify(phone: string, requestId: string, code: string): Promise<ClientResult<OtpVerifyResult>>;
47
+ verify(phone: string, requestId: string, code: string, forwardedHeaders?: Record<string, string>): Promise<ClientResult<OtpVerifyResult>>;
45
48
  }
46
49
  export interface OrdersNamespace {
47
50
  /**
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { CreatedOrderSchema, IdResponseSchema, OrderTrackingSchema, OtpSendResultSchema, OtpVerifyResultSchema, } from "../schemas.js";
15
15
  import { STOREFRONT_OPERATIONS } from "../domain.js";
16
- import { reportDegradedRead } from "./core.js";
16
+ import { isNotFoundAnswer, reportDegradedRead } from "./core.js";
17
17
  /**
18
18
  * Create orders namespace
19
19
  */
@@ -34,7 +34,8 @@ export function createOrdersNamespace(core) {
34
34
  return tracking ?? null;
35
35
  }
36
36
  catch (error) {
37
- reportDegradedRead(core.config, error, "orders.tracking");
37
+ if (!isNotFoundAnswer(error))
38
+ reportDegradedRead(core.config, error, "orders.tracking");
38
39
  return null;
39
40
  }
40
41
  },
@@ -60,16 +61,21 @@ export function createOrdersNamespace(core) {
60
61
  },
61
62
  },
62
63
  otp: {
63
- async send(phone) {
64
+ async send(phone, forwardedHeaders) {
64
65
  // core.post flattens the server's `context` (windowSeconds) and
65
66
  // `code` into the error result — exactly what the OTP step reads.
66
67
  return core.post("/store/otp/send", { phone }, {
68
+ ...(forwardedHeaders ? { headers: forwardedHeaders } : {}),
67
69
  schema: OtpSendResultSchema,
68
70
  operation: STOREFRONT_OPERATIONS.ordersOtpSend,
69
71
  });
70
72
  },
71
- async verify(phone, requestId, code) {
72
- return core.post("/store/otp/verify", { phone, requestId, code }, { schema: OtpVerifyResultSchema, operation: STOREFRONT_OPERATIONS.ordersOtpVerify });
73
+ async verify(phone, requestId, code, forwardedHeaders) {
74
+ return core.post("/store/otp/verify", { phone, requestId, code }, {
75
+ ...(forwardedHeaders ? { headers: forwardedHeaders } : {}),
76
+ schema: OtpVerifyResultSchema,
77
+ operation: STOREFRONT_OPERATIONS.ordersOtpVerify,
78
+ });
73
79
  },
74
80
  },
75
81
  };
@@ -12,8 +12,7 @@
12
12
  * newer document's instances reach the theme verbatim.
13
13
  *
14
14
  * Fetched fresh on every call (no process-lifetime cache — a save must reach
15
- * the next render, subject only to the theme's route cache); identical
16
- * parallel calls coalesce into one request like every other read.
15
+ * the next render, subject only to the theme's route cache).
17
16
  */
18
17
  import { type ClientCore } from "./core.js";
19
18
  import type { PageDocumentPayload } from "../types.js";
@@ -12,12 +12,11 @@
12
12
  * newer document's instances reach the theme verbatim.
13
13
  *
14
14
  * Fetched fresh on every call (no process-lifetime cache — a save must reach
15
- * the next render, subject only to the theme's route cache); identical
16
- * parallel calls coalesce into one request like every other read.
15
+ * the next render, subject only to the theme's route cache).
17
16
  */
18
17
  import { PageDocumentPayloadSchema } from "../schemas.js";
19
18
  import { STOREFRONT_OPERATIONS } from "../domain.js";
20
- import { reportDegradedRead } from "./core.js";
19
+ import { isNotFoundAnswer, reportDegradedRead } from "./core.js";
21
20
  /**
22
21
  * Create the page-data namespace.
23
22
  */
@@ -29,7 +28,8 @@ export function createPageDataNamespace(core) {
29
28
  return document ?? null;
30
29
  }
31
30
  catch (error) {
32
- reportDegradedRead(core.config, error, "pageData.get");
31
+ if (!isNotFoundAnswer(error))
32
+ reportDegradedRead(core.config, error, "pageData.get");
33
33
  return null;
34
34
  }
35
35
  },
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Deep module: hides URL construction, slug encoding, and transport behind
5
5
  * two calls. Pages are fetched fresh every request (a merchant edit is live
6
- * on the next render); parallel identical calls coalesce into one request.
6
+ * on the next render).
7
7
  *
8
8
  * Failure contracts:
9
9
  * - `get(slug)` → null means "not published" — the theme 404s, never a
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Deep module: hides URL construction, slug encoding, and transport behind
5
5
  * two calls. Pages are fetched fresh every request (a merchant edit is live
6
- * on the next render); parallel identical calls coalesce into one request.
6
+ * on the next render).
7
7
  *
8
8
  * Failure contracts:
9
9
  * - `get(slug)` → null means "not published" — the theme 404s, never a
@@ -14,7 +14,7 @@
14
14
  import { LandingPageSchema, StorePagePublicSchema } from "../schemas.js";
15
15
  import { STOREFRONT_OPERATIONS } from "../domain.js";
16
16
  import { localizedPath } from "./paths.js";
17
- import { reportDegradedRead } from "./core.js";
17
+ import { isNotFoundAnswer, reportDegradedRead } from "./core.js";
18
18
  /**
19
19
  * Create pages namespace
20
20
  */
@@ -26,7 +26,8 @@ export function createPagesNamespace(core) {
26
26
  return page ?? null;
27
27
  }
28
28
  catch (error) {
29
- reportDegradedRead(core.config, error, "pages.get");
29
+ if (!isNotFoundAnswer(error))
30
+ reportDegradedRead(core.config, error, "pages.get");
30
31
  return null;
31
32
  }
32
33
  },
@@ -36,7 +37,8 @@ export function createPagesNamespace(core) {
36
37
  return landingPage ?? null;
37
38
  }
38
39
  catch (error) {
39
- reportDegradedRead(core.config, error, "pages.landing");
40
+ if (!isNotFoundAnswer(error))
41
+ reportDegradedRead(core.config, error, "pages.landing");
40
42
  return null;
41
43
  }
42
44
  },
@@ -15,7 +15,7 @@ import { z } from "zod";
15
15
  import { IdResponseSchema, ProductListSchema, ProductSchema, ReviewListEnvelopeSchema } from "../schemas.js";
16
16
  import { STOREFRONT_OPERATIONS } from "../domain.js";
17
17
  import { localizedPath } from "./paths.js";
18
- import { reportDegradedRead } from "./core.js";
18
+ import { isNotFoundAnswer, reportDegradedRead } from "./core.js";
19
19
  /**
20
20
  * Create products namespace
21
21
  */
@@ -27,7 +27,8 @@ export function createProductsNamespace(core) {
27
27
  return product ?? null;
28
28
  }
29
29
  catch (error) {
30
- reportDegradedRead(core.config, error, "products.get");
30
+ if (!isNotFoundAnswer(error))
31
+ reportDegradedRead(core.config, error, "products.get");
31
32
  return null;
32
33
  }
33
34
  },
@@ -6,8 +6,7 @@
6
6
  * a single wilaya's pair when the caller only needs one.
7
7
  *
8
8
  * Fetched fresh every request (merchants adjust delivery pricing and expect
9
- * the next render to show it); parallel identical calls coalesce into one
10
- * request.
9
+ * the next render to show it).
11
10
  *
12
11
  * Failure contract: resolves to `{}` /
13
12
  * `null` on any failure — never throws. Absent wilayas have no rule and are
@@ -6,8 +6,7 @@
6
6
  * a single wilaya's pair when the caller only needs one.
7
7
  *
8
8
  * Fetched fresh every request (merchants adjust delivery pricing and expect
9
- * the next render to show it); parallel identical calls coalesce into one
10
- * request.
9
+ * the next render to show it).
11
10
  *
12
11
  * Failure contract: resolves to `{}` /
13
12
  * `null` on any failure — never throws. Absent wilayas have no rule and are
package/dist/domain.d.ts CHANGED
@@ -35,6 +35,15 @@ export interface StoreScope {
35
35
  * success.
36
36
  */
37
37
  onError?: (error: DomainError) => void;
38
+ /**
39
+ * How long one read may take, retries included, before it gives up: the read
40
+ * then degrades like any other failed read and `onError` receives a
41
+ * `REQUEST_TIMEOUT`. A server that stops answering costs a page this long
42
+ * instead of holding it open. Defaults to 10 000 ms; `0` removes the limit.
43
+ * Writes have no default limit: a write cut off by a timer may already have
44
+ * happened, and retrying it could place the same order twice.
45
+ */
46
+ readonly readTimeoutMs?: number;
38
47
  }
39
48
  /** The server's hard limits for one canonical browser basket. */
40
49
  export declare const MAX_BASKET_LINES: 20;
@@ -69,9 +69,11 @@ export interface ProxyEndpointConfig<TInput, TOutput> {
69
69
  */
70
70
  successStatus?: number;
71
71
  /**
72
- * Status code for upstream failures (default: 502).
73
- * Typical choices: 502 for cart/abandoned/otp-send, 422 for otp-verify,
74
- * 400 for submit-review.
72
+ * Status code for every upstream failure. Leave it unset: the status then
73
+ * follows the failure's `domainError` — 400 for a validation refusal, 422 for
74
+ * a business refusal, 429 when the server named a wait, 504 for a timeout and
75
+ * 502 for anything the shopper cannot fix (a bad store key, a server fault,
76
+ * a dropped connection, or a failure that carries no `domainError`).
75
77
  */
76
78
  errorStatus?: number;
77
79
  /**
@@ -31,7 +31,7 @@ import { collectForwardedHeaders } from "../actions.js";
31
31
  * ```
32
32
  */
33
33
  export function createProxyEndpoint(config) {
34
- const { method, client, inputSchema, queryParams = [], pathParams = {}, csrfProtection = method === "POST", forwardAttribution = false, successStatus = 200, errorStatus = 502, successBody, noContent = false, } = config;
34
+ const { method, client, inputSchema, queryParams = [], pathParams = {}, csrfProtection = method === "POST", forwardAttribution = false, successStatus = 200, errorStatus, successBody, noContent = false, } = config;
35
35
  return async (context) => {
36
36
  const { request, params } = context;
37
37
  // CSRF Protection: reject cross-origin requests
@@ -68,8 +68,8 @@ export function createProxyEndpoint(config) {
68
68
  try {
69
69
  result = await client(input, headers);
70
70
  }
71
- catch (error) {
72
- return jsonResponse({ error: error instanceof Error ? error.message : "Upstream request failed" }, errorStatus);
71
+ catch {
72
+ return jsonResponse({ error: "The request could not be completed", code: "PROXY_UPSTREAM_FAILED" }, errorStatus ?? 502);
73
73
  }
74
74
  // Handle client errors — forward every diagnostic field the client
75
75
  // attached (code, windowSeconds, attemptsRemaining, terminal, ...)
@@ -78,7 +78,7 @@ export function createProxyEndpoint(config) {
78
78
  const extra = { ...result };
79
79
  delete extra.success;
80
80
  delete extra.error;
81
- return jsonResponse({ error: result.error, ...extra }, errorStatus);
81
+ return jsonResponse({ error: result.error, ...extra }, errorStatus ?? failureStatus(result.domainError));
82
82
  }
83
83
  // Return success response
84
84
  if (noContent) {
@@ -88,6 +88,23 @@ export function createProxyEndpoint(config) {
88
88
  return jsonResponse(body, successStatus);
89
89
  };
90
90
  }
91
+ /**
92
+ * The status a failure answers the browser with. The server's category decides:
93
+ * a refusal the shopper can act on stays a 4xx, and everything else is the
94
+ * gateway's failure, never the shopper's.
95
+ */
96
+ function failureStatus(domainError) {
97
+ if (typeof domainError !== "object" || domainError === null)
98
+ return 502;
99
+ const { category, code, retryAfterSeconds } = domainError;
100
+ if (category === "validation")
101
+ return 400;
102
+ if (category === "business")
103
+ return typeof retryAfterSeconds === "number" ? 429 : 422;
104
+ if (category === "transport" && code === "REQUEST_TIMEOUT")
105
+ return 504;
106
+ return 502;
107
+ }
91
108
  /**
92
109
  * Parse GET request input from query params and path params
93
110
  */
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The storefront's same-origin API routes, ready to export.
3
+ *
4
+ * Every theme needs the same eight routes between the shopper's browser and
5
+ * the platform: OTP send and verify, cart validation, abandoned-checkout
6
+ * tracking and its conversion, communes, and reviews (list and submit). What
7
+ * each one must get right is decided here once — the cross-origin check on
8
+ * every POST, the shopper's address forwarded wherever the server counts or
9
+ * attributes per shopper, the input bounds, and the response shape the
10
+ * browser reads — so a theme's route file is one line:
11
+ *
12
+ * ```ts
13
+ * // src/pages/api/otp/send.ts
14
+ * import { routes } from "@/theme/api-routes";
15
+ * export const POST = routes.otpSend;
16
+ * ```
17
+ *
18
+ * The theme decides only which client serves the request — its own,
19
+ * per-request client, so the language and the request lifetime are the
20
+ * page's. A failure answers with the status the server's category implies
21
+ * (see `createProxyEndpoint`), and the server's diagnostic fields ride along.
22
+ */
23
+ import type { StoreClient } from "../client/index.js";
24
+ import { type AstroAPIContext } from "./proxy.js";
25
+ export interface StorefrontRoutesOptions<C extends AstroAPIContext> {
26
+ /** The store client that serves this request — the request's own, never a shared one. */
27
+ client: (context: C) => StoreClient;
28
+ }
29
+ export type StorefrontApiRoute<C extends AstroAPIContext> = (context: C) => Promise<Response>;
30
+ export interface StorefrontRoutes<C extends AstroAPIContext> {
31
+ /** POST `{ phone }` → `{ data: OtpSendResult }`. */
32
+ readonly otpSend: StorefrontApiRoute<C>;
33
+ /** POST `{ phone, requestId, code }` → `{ data: OtpVerifyResult }`. */
34
+ readonly otpVerify: StorefrontApiRoute<C>;
35
+ /** POST `{ items }` → `{ success: true, data: CartValidation }`. */
36
+ readonly cartValidate: StorefrontApiRoute<C>;
37
+ /** POST an `AbandonedCheckoutInput` → 204. */
38
+ readonly abandonedUpsert: StorefrontApiRoute<C>;
39
+ /** POST `{ sessionId, orderId, orderNumber }` → 204. */
40
+ readonly abandonedConvert: StorefrontApiRoute<C>;
41
+ /** GET, path param `wilayaId` → `{ data: Commune[] }`. */
42
+ readonly communes: StorefrontApiRoute<C>;
43
+ /** GET, path param `productId`, query `limit`/`offset` → `{ data: Review[], total }`. */
44
+ readonly reviewsList: StorefrontApiRoute<C>;
45
+ /** POST a `ReviewInput` → 201 `{ success: true, data: { id } }`. */
46
+ readonly reviewSubmit: StorefrontApiRoute<C>;
47
+ }
48
+ export declare const REVIEWS_PAGE_DEFAULT = 20;
49
+ export declare const REVIEWS_PAGE_MAX = 50;
50
+ export declare function createStorefrontRoutes<C extends AstroAPIContext = AstroAPIContext>(options: StorefrontRoutesOptions<C>): StorefrontRoutes<C>;