@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 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
- Parallel identical GET requests made through the same client are coalesced.
166
- That makes it safe for independent page loaders to ask for the same
167
- configuration without manually coordinating them.
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
- - Parallel identical GET requests are deduplicated per client instance.
443
- - config.get(), categories.list(), and geo.communes() use the client
444
- lifetime cache after a successful response.
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.
@@ -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 coalesces
21
- * with another caller's request, and returns a REQUEST_ABORTED DomainError
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
  *
@@ -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
- }
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
- // 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)
@@ -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
@@ -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,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 { PageDocumentPayloadSchema } from "../schemas.js";
19
18
  import { STOREFRONT_OPERATIONS } from "../domain.js";
@@ -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
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Typed, validated SDK for Wocart cash-on-delivery storefronts: catalog reads, basket, checkout, OTP and order tracking, with Astro adapters.",
5
5
  "keywords": [
6
6
  "wocart",