@wocart/storefront 1.0.0 → 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 +29 -0
- package/README.md +1 -0
- package/USAGE.md +12 -7
- package/dist/client/core.d.ts +4 -2
- package/dist/client/core.js +61 -77
- package/dist/client/index.d.ts +4 -3
- package/dist/client/index.js +4 -3
- package/dist/client/page-data.d.ts +1 -2
- package/dist/client/page-data.js +1 -2
- package/dist/client/pages.d.ts +1 -1
- package/dist/client/pages.js +1 -1
- package/dist/client/shipping.d.ts +1 -2
- package/dist/client/shipping.js +1 -2
- package/dist/domain.d.ts +9 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ follows [semantic versioning](https://semver.org/): breaking changes ship only
|
|
|
5
5
|
in a new major version, and an export is marked `@deprecated` for at least one
|
|
6
6
|
minor release before a major removes it.
|
|
7
7
|
|
|
8
|
+
## 1.1.0 — 2026-10-14
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **A read never waits on another read's pending request.** The client used to
|
|
13
|
+
merge an identical read with one already in flight, and its opt-in caches
|
|
14
|
+
held the pending request itself. A client kept across requests (one per
|
|
15
|
+
isolate) could therefore make a request wait on a read another request had
|
|
16
|
+
started; on Cloudflare Workers that read never settles once the request
|
|
17
|
+
that started it is cancelled (a shopper leaving mid-load, a navigation
|
|
18
|
+
interrupted by a click), and every later request for the same URL hung.
|
|
19
|
+
Each call now makes its own request, and the caches keep finished values
|
|
20
|
+
only.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`readTimeoutMs`** on `createStoreClient` bounds every read, retries
|
|
25
|
+
included: 10 000 ms by default, `0` for no limit. A read that runs out of
|
|
26
|
+
time degrades like any failed read and reports `REQUEST_TIMEOUT` to
|
|
27
|
+
`onError`, so a server that stops answering costs a page seconds instead of
|
|
28
|
+
holding it open. Writes keep no default limit: a write cut off by a timer
|
|
29
|
+
may already have happened.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Parallel identical reads are no longer coalesced into one request. A page
|
|
34
|
+
that asks for the same data from several places should ask once and share
|
|
35
|
+
the result within its render.
|
|
36
|
+
|
|
8
37
|
## 1.0.0 — 2026-10-13
|
|
9
38
|
|
|
10
39
|
The first public release.
|
package/README.md
CHANGED
|
@@ -115,6 +115,7 @@ import { createBasketStore, formatMoney } from "@wocart/storefront";
|
|
|
115
115
|
| `locale` | no | read merchant text in a published store language (`"fr"`); one client per language |
|
|
116
116
|
| `theme` | no | your theme's key (`defineTheme({ key })`), to receive the merchant's edits to your theme's text |
|
|
117
117
|
| `onError` | no | observability hook for degraded reads |
|
|
118
|
+
| `readTimeoutMs` | no | how long one read may take, retries included, before it degrades (default 10 000; `0` for no limit) |
|
|
118
119
|
|
|
119
120
|
A storefront deployed by Wocart receives its origin and key as the
|
|
120
121
|
`COD_SERVER_URL` and `STORE_API_KEY` environment variables. The SDK reads no
|
package/USAGE.md
CHANGED
|
@@ -162,9 +162,11 @@ const [config, featuredProducts] = await Promise.all([
|
|
|
162
162
|
]);
|
|
163
163
|
~~~
|
|
164
164
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
Every call makes its own request; a call never waits on another call's
|
|
166
|
+
pending request, even an identical one. Within one render, ask once and share
|
|
167
|
+
the result (`Promise.all`, or a per-request memo) when several parts of a page
|
|
168
|
+
need the same data. A client never holds pending work across calls, so one
|
|
169
|
+
kept for several requests cannot leave a request waiting on another's read.
|
|
168
170
|
|
|
169
171
|
### Available imports
|
|
170
172
|
|
|
@@ -438,10 +440,13 @@ conversion event.
|
|
|
438
440
|
|
|
439
441
|
### Caching and retries
|
|
440
442
|
|
|
441
|
-
- GET requests are fresh by default.
|
|
442
|
-
-
|
|
443
|
-
|
|
444
|
-
|
|
443
|
+
- GET requests are fresh by default, and each call makes its own request.
|
|
444
|
+
- config.get(), categories.list(), and geo.communes() keep their finished
|
|
445
|
+
result for the client's lifetime; a call made while the first is still
|
|
446
|
+
pending makes its own request rather than waiting on it.
|
|
447
|
+
- Every read is bounded by `readTimeoutMs` (10 seconds by default), retries
|
|
448
|
+
included; a read that runs out of time degrades and reports
|
|
449
|
+
`REQUEST_TIMEOUT` to `onError`. Writes have no default limit.
|
|
445
450
|
- null results and failures are not cached, so a temporary failure can recover
|
|
446
451
|
on the next call.
|
|
447
452
|
- GET network failures use two short exponential backoff retries.
|
package/dist/client/core.d.ts
CHANGED
|
@@ -17,8 +17,8 @@ export type ClientConfig = StoreScope;
|
|
|
17
17
|
/**
|
|
18
18
|
* Options every request accepts.
|
|
19
19
|
*
|
|
20
|
-
* `signal` cancels the request — an aborted call never retries, never
|
|
21
|
-
*
|
|
20
|
+
* `signal` cancels the request — an aborted call never retries, is never
|
|
21
|
+
* cached, and returns a REQUEST_ABORTED DomainError
|
|
22
22
|
* (a read throws instead). `timeoutMs` bounds the whole operation, retries
|
|
23
23
|
* included, and reads as REQUEST_TIMEOUT. `operation` names the call in the
|
|
24
24
|
* error it produces; namespaces pass a stable label (`orders.otp.send`) so logs
|
|
@@ -61,6 +61,8 @@ export interface ClientCore {
|
|
|
61
61
|
post: <TInput, TOutput>(path: string, body: TInput, opts?: PostOptions<TOutput>) => Promise<ClientResult<TOutput>>;
|
|
62
62
|
patch: <TInput, TOutput>(path: string, body: TInput, optsOrSchema?: z.Schema<TOutput> | PatchOptions<TOutput>) => Promise<ClientResult<TOutput>>;
|
|
63
63
|
}
|
|
64
|
+
/** How long a read may take, retries included, unless the client sets another limit. */
|
|
65
|
+
export declare const DEFAULT_READ_TIMEOUT_MS = 10000;
|
|
64
66
|
/**
|
|
65
67
|
* Create client core infrastructure
|
|
66
68
|
*
|
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
|
-
}
|
|
335
|
-
}
|
|
336
|
-
// Check in-flight requests for deduplication
|
|
337
|
-
if (shared) {
|
|
338
|
-
const inflight = inflightRequests.get(url);
|
|
339
|
-
if (inflight) {
|
|
340
|
-
return (await inflight);
|
|
341
|
-
}
|
|
346
|
+
if (cacheable && valueCache.has(url)) {
|
|
347
|
+
return valueCache.get(url);
|
|
342
348
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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)
|
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
|
|
@@ -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,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 { PageDocumentPayloadSchema } from "../schemas.js";
|
|
19
18
|
import { STOREFRONT_OPERATIONS } from "../domain.js";
|
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
|
|
@@ -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;
|
package/package.json
CHANGED