@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.
- package/CHANGELOG.md +80 -0
- package/README.md +2 -1
- package/USAGE.md +117 -56
- package/dist/actions.d.ts +5 -4
- package/dist/actions.js +14 -9
- package/dist/astro.d.ts +3 -1
- package/dist/astro.js +2 -1
- package/dist/client/core.d.ts +12 -2
- package/dist/client/core.js +73 -79
- package/dist/client/index.d.ts +4 -3
- package/dist/client/index.js +4 -3
- package/dist/client/orders.d.ts +5 -2
- package/dist/client/orders.js +11 -5
- package/dist/client/page-data.d.ts +1 -2
- package/dist/client/page-data.js +4 -4
- package/dist/client/pages.d.ts +1 -1
- package/dist/client/pages.js +6 -4
- package/dist/client/products.js +3 -2
- package/dist/client/shipping.d.ts +1 -2
- package/dist/client/shipping.js +1 -2
- package/dist/domain.d.ts +9 -0
- package/dist/endpoints/proxy.d.ts +5 -3
- package/dist/endpoints/proxy.js +21 -4
- package/dist/endpoints/storefront-routes.d.ts +50 -0
- package/dist/endpoints/storefront-routes.js +133 -0
- package/dist/middleware.d.ts +29 -35
- package/dist/middleware.js +32 -42
- package/package.json +1 -1
package/dist/client/core.js
CHANGED
|
@@ -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
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
315
|
+
* GET request with opt-in caching.
|
|
305
316
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
* `{ cache: true }` for read-mostly data (config, categories,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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(
|
|
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
|
|
373
|
+
return json;
|
|
381
374
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
*
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
package/dist/client/index.js
CHANGED
|
@@ -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:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
package/dist/client/orders.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/client/orders.js
CHANGED
|
@@ -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
|
-
|
|
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 }, {
|
|
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)
|
|
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";
|
package/dist/client/page-data.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
31
|
+
if (!isNotFoundAnswer(error))
|
|
32
|
+
reportDegradedRead(core.config, error, "pageData.get");
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
35
|
},
|
package/dist/client/pages.d.ts
CHANGED
|
@@ -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)
|
|
6
|
+
* on the next render).
|
|
7
7
|
*
|
|
8
8
|
* Failure contracts:
|
|
9
9
|
* - `get(slug)` → null means "not published" — the theme 404s, never a
|
package/dist/client/pages.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
40
|
+
if (!isNotFoundAnswer(error))
|
|
41
|
+
reportDegradedRead(core.config, error, "pages.landing");
|
|
40
42
|
return null;
|
|
41
43
|
}
|
|
42
44
|
},
|
package/dist/client/products.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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/client/shipping.js
CHANGED
|
@@ -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)
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
/**
|
package/dist/endpoints/proxy.js
CHANGED
|
@@ -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
|
|
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
|
|
72
|
-
return jsonResponse({ error:
|
|
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>;
|