@snowcone-app/sdk 0.3.3 → 0.3.5

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/dist/index.d.cts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { createDevFetcher } from './dev-fetcher.cjs';
2
2
  import { PublicDesign, PublicFill, PublicOptions } from '@snowcone-app/mockup-url';
3
- import { O as OptionAttribute, a as OptionSelection, C as Combination, F as FrameworkAdapter, b as ComponentProps, c as ComponentDescriptor, d as ComponentState, e as ComponentContext, f as ComponentLifecycleHooks, E as EventHandler, g as FrameworkUtilities, P as PlacementSettings, M as MockupResult, R as RealtimeMockupService } from './websocket-Poy8LZNA.cjs';
4
- export { A as AdapterRegistry, h as ProductComponentContext, i as RealtimeMockupCallbacks, j as RealtimeMockupState, k as RenderResult, W as WebSocketConfig, l as WebSocketMessage, m as adapterRegistry, n as computeDisabledChoices, o as createComponent, p as defineComponent, q as deriveDefaultSelection, r as findBestCombination, s as getPricePreview, t as isOptionAvailable, u as resolveBestCombination } from './websocket-Poy8LZNA.cjs';
3
+ import { O as OptionAttribute, a as OptionSelection, C as Combination, F as FrameworkAdapter, b as ComponentProps, c as ComponentDescriptor, d as ComponentState, e as ComponentContext, f as ComponentLifecycleHooks, E as EventHandler, g as FrameworkUtilities, P as PlacementSettings, M as MockupResult, R as RealtimeMockupService } from './websocket-hXpNcyEn.cjs';
4
+ export { A as AdapterRegistry, h as ProductComponentContext, i as RealtimeMockupCallbacks, j as RealtimeMockupState, k as RenderResult, W as WebSocketConfig, l as WebSocketMessage, m as adapterRegistry, n as computeDisabledChoices, o as createComponent, p as defineComponent, q as deriveDefaultSelection, r as findBestCombination, s as getPricePreview, t as isOptionAvailable, u as resolveBestCombination } from './websocket-hXpNcyEn.cjs';
5
5
 
6
6
  interface CatalogProduct$2 {
7
7
  id: string;
@@ -2584,8 +2584,7 @@ declare function fetchRealtimeGrant(grantUrl: string, shop: string, fetchImpl?:
2584
2584
  * product: { productId: 'BEEB77', mockupIds: ['front'] },
2585
2585
  * });
2586
2586
  * session.onMockups((results) => { img.src = results[0].imageUrl; });
2587
- * await session.connect();
2588
- * session.renderState('Front', serializeStateForServer(canvasState));
2587
+ * await session.renderState('Front', serializeStateForServer(canvasState));
2589
2588
  *
2590
2589
  * For the low-level escape hatch, use {@link RealtimeMockupService} directly.
2591
2590
  */
@@ -2656,6 +2655,16 @@ interface RenderProduct {
2656
2655
  * catalog product's `options` object verbatim.
2657
2656
  */
2658
2657
  options?: RenderProductOptions;
2658
+ /**
2659
+ * The product's valid placement labels (the catalog product's
2660
+ * `placements[].label`, e.g. `['Front']`). Optional, but passing it lets the
2661
+ * session WARN LOUDLY when a `renderState(placement, ...)` call uses a name
2662
+ * that isn't a real placement — turning the classic silent blank-mockup
2663
+ * (server waits on `incomplete_canvas_placements`) into an actionable
2664
+ * dev-time message. You can pass the catalog product's
2665
+ * `placements.map(p => p.label)` verbatim.
2666
+ */
2667
+ placements?: string[];
2659
2668
  /** Render width in px (default 1000). */
2660
2669
  width?: number;
2661
2670
  /** Per-placement settings (tiling, scale mode). */
@@ -2756,6 +2765,14 @@ declare class RenderSession {
2756
2765
  getMockups(): MockupResult[];
2757
2766
  /** Close the WebSocket and stop auto-renew. */
2758
2767
  close(): void;
2768
+ /**
2769
+ * Emit loud, non-fatal warnings for the two ways a placement name silently
2770
+ * produces a blank mockup: (1) the name isn't one of the product's known
2771
+ * `placements` labels, and (2) no artboard in `state` is named after it. Each
2772
+ * warning goes to BOTH `console.warn` and the `onError` callback (if wired) so
2773
+ * a dev sees it whichever channel they watch. Never throws.
2774
+ */
2775
+ private warnPlacement;
2759
2776
  /**
2760
2777
  * (Re)arm the render watchdog. Clears any prior timer and, unless disabled
2761
2778
  * (`renderTimeoutMs <= 0`), starts a fresh one. If it fires before a mockup
@@ -2763,6 +2780,15 @@ declare class RenderSession {
2763
2780
  * server/transport errors — with a hint at the most common cause.
2764
2781
  */
2765
2782
  private armWatchdog;
2783
+ /**
2784
+ * Build the watchdog timeout message from the *observed* session state rather
2785
+ * than a single hardcoded guess. The old message always blamed a missing
2786
+ * `variantId` — which sent a clean-room dogfood chasing the wrong cause when
2787
+ * the real problem was elsewhere. Report what's actually true: is the socket
2788
+ * open, did config get acknowledged, and only then fall back to the
2789
+ * variant/catalog hypotheses, naming the exact product so it's checkable.
2790
+ */
2791
+ private timeoutMessage;
2766
2792
  /** Cancel the pending render watchdog, if any. */
2767
2793
  private clearWatchdog;
2768
2794
  /** Escape hatch: the underlying low-level service. */
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { createDevFetcher } from './dev-fetcher.js';
2
2
  import { PublicDesign, PublicFill, PublicOptions } from '@snowcone-app/mockup-url';
3
- import { O as OptionAttribute, a as OptionSelection, C as Combination, F as FrameworkAdapter, b as ComponentProps, c as ComponentDescriptor, d as ComponentState, e as ComponentContext, f as ComponentLifecycleHooks, E as EventHandler, g as FrameworkUtilities, P as PlacementSettings, M as MockupResult, R as RealtimeMockupService } from './websocket-Poy8LZNA.js';
4
- export { A as AdapterRegistry, h as ProductComponentContext, i as RealtimeMockupCallbacks, j as RealtimeMockupState, k as RenderResult, W as WebSocketConfig, l as WebSocketMessage, m as adapterRegistry, n as computeDisabledChoices, o as createComponent, p as defineComponent, q as deriveDefaultSelection, r as findBestCombination, s as getPricePreview, t as isOptionAvailable, u as resolveBestCombination } from './websocket-Poy8LZNA.js';
3
+ import { O as OptionAttribute, a as OptionSelection, C as Combination, F as FrameworkAdapter, b as ComponentProps, c as ComponentDescriptor, d as ComponentState, e as ComponentContext, f as ComponentLifecycleHooks, E as EventHandler, g as FrameworkUtilities, P as PlacementSettings, M as MockupResult, R as RealtimeMockupService } from './websocket-hXpNcyEn.js';
4
+ export { A as AdapterRegistry, h as ProductComponentContext, i as RealtimeMockupCallbacks, j as RealtimeMockupState, k as RenderResult, W as WebSocketConfig, l as WebSocketMessage, m as adapterRegistry, n as computeDisabledChoices, o as createComponent, p as defineComponent, q as deriveDefaultSelection, r as findBestCombination, s as getPricePreview, t as isOptionAvailable, u as resolveBestCombination } from './websocket-hXpNcyEn.js';
5
5
 
6
6
  interface CatalogProduct$2 {
7
7
  id: string;
@@ -2584,8 +2584,7 @@ declare function fetchRealtimeGrant(grantUrl: string, shop: string, fetchImpl?:
2584
2584
  * product: { productId: 'BEEB77', mockupIds: ['front'] },
2585
2585
  * });
2586
2586
  * session.onMockups((results) => { img.src = results[0].imageUrl; });
2587
- * await session.connect();
2588
- * session.renderState('Front', serializeStateForServer(canvasState));
2587
+ * await session.renderState('Front', serializeStateForServer(canvasState));
2589
2588
  *
2590
2589
  * For the low-level escape hatch, use {@link RealtimeMockupService} directly.
2591
2590
  */
@@ -2656,6 +2655,16 @@ interface RenderProduct {
2656
2655
  * catalog product's `options` object verbatim.
2657
2656
  */
2658
2657
  options?: RenderProductOptions;
2658
+ /**
2659
+ * The product's valid placement labels (the catalog product's
2660
+ * `placements[].label`, e.g. `['Front']`). Optional, but passing it lets the
2661
+ * session WARN LOUDLY when a `renderState(placement, ...)` call uses a name
2662
+ * that isn't a real placement — turning the classic silent blank-mockup
2663
+ * (server waits on `incomplete_canvas_placements`) into an actionable
2664
+ * dev-time message. You can pass the catalog product's
2665
+ * `placements.map(p => p.label)` verbatim.
2666
+ */
2667
+ placements?: string[];
2659
2668
  /** Render width in px (default 1000). */
2660
2669
  width?: number;
2661
2670
  /** Per-placement settings (tiling, scale mode). */
@@ -2756,6 +2765,14 @@ declare class RenderSession {
2756
2765
  getMockups(): MockupResult[];
2757
2766
  /** Close the WebSocket and stop auto-renew. */
2758
2767
  close(): void;
2768
+ /**
2769
+ * Emit loud, non-fatal warnings for the two ways a placement name silently
2770
+ * produces a blank mockup: (1) the name isn't one of the product's known
2771
+ * `placements` labels, and (2) no artboard in `state` is named after it. Each
2772
+ * warning goes to BOTH `console.warn` and the `onError` callback (if wired) so
2773
+ * a dev sees it whichever channel they watch. Never throws.
2774
+ */
2775
+ private warnPlacement;
2759
2776
  /**
2760
2777
  * (Re)arm the render watchdog. Clears any prior timer and, unless disabled
2761
2778
  * (`renderTimeoutMs <= 0`), starts a fresh one. If it fires before a mockup
@@ -2763,6 +2780,15 @@ declare class RenderSession {
2763
2780
  * server/transport errors — with a hint at the most common cause.
2764
2781
  */
2765
2782
  private armWatchdog;
2783
+ /**
2784
+ * Build the watchdog timeout message from the *observed* session state rather
2785
+ * than a single hardcoded guess. The old message always blamed a missing
2786
+ * `variantId` — which sent a clean-room dogfood chasing the wrong cause when
2787
+ * the real problem was elsewhere. Report what's actually true: is the socket
2788
+ * open, did config get acknowledged, and only then fall back to the
2789
+ * variant/catalog hypotheses, naming the exact product so it's checkable.
2790
+ */
2791
+ private timeoutMessage;
2766
2792
  /** Cancel the pending render watchdog, if any. */
2767
2793
  private clearWatchdog;
2768
2794
  /** Escape hatch: the underlying low-level service. */
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  import {
5
5
  REALTIME_WS_URL,
6
6
  RealtimeMockupService
7
- } from "./chunk-D5ZRGKA5.js";
7
+ } from "./chunk-YTOTCNEW.js";
8
8
 
9
9
  // src/validation.ts
10
10
  import { z } from "zod";
@@ -1642,14 +1642,12 @@ function buildMockupUrl2(options, cfg) {
1642
1642
  );
1643
1643
  }
1644
1644
  function resolveMockupBaseUrl() {
1645
- const env = typeof process !== "undefined" ? process.env : void 0;
1646
1645
  const winConfig = typeof window !== "undefined" && window.snowcone || {};
1647
- return winConfig.mockupUrl || env?.SNOWCONE_IMAGE_URL || env?.NEXT_PUBLIC_MERCH_MOCKUP_URL || "https://cdn.snowcone.app";
1646
+ return winConfig.mockupUrl || typeof process !== "undefined" && process.env?.SNOWCONE_IMAGE_URL || typeof process !== "undefined" && process.env?.NEXT_PUBLIC_MERCH_MOCKUP_URL || "https://cdn.snowcone.app";
1648
1647
  }
1649
1648
  function resolveShop() {
1650
- const env = typeof process !== "undefined" ? process.env : void 0;
1651
1649
  const winConfig = typeof window !== "undefined" && window.snowcone || {};
1652
- return winConfig.shop || env?.SNOWCONE_SHOP_ID || env?.NEXT_PUBLIC_SNOWCONE_SHOP_ID || "SHOP_NOT_CONFIGURED";
1650
+ return winConfig.shop || typeof process !== "undefined" && process.env?.SNOWCONE_SHOP_ID || typeof process !== "undefined" && process.env?.NEXT_PUBLIC_SNOWCONE_SHOP_ID || "SHOP_NOT_CONFIGURED";
1653
1651
  }
1654
1652
  function mockupUrl(options) {
1655
1653
  const mockupBaseUrl = resolveMockupBaseUrl();
@@ -4714,6 +4712,20 @@ function assertVariantSelected(product) {
4714
4712
  );
4715
4713
  }
4716
4714
  }
4715
+ function warnUnknownPlacement(product, placement) {
4716
+ const placements = product.placements;
4717
+ if (!placements?.length) return null;
4718
+ if (placements.includes(placement)) return null;
4719
+ return `RenderSession: placement "${placement}" doesn't match product ${product.productId}'s placements: [${placements.join(", ")}]. Your artboard name + renderState placement arg must equal the catalog placements[].label.`;
4720
+ }
4721
+ function warnPlacementArtboardMismatch(placement, state) {
4722
+ const artboards = state.artboards;
4723
+ if (!Array.isArray(artboards) || artboards.length === 0) return null;
4724
+ const names = artboards.map((a) => a.name).filter((n) => typeof n === "string");
4725
+ if (names.length === 0) return null;
4726
+ if (names.includes(placement)) return null;
4727
+ return `RenderSession: renderState placement "${placement}" has no matching artboard in the state (artboards: [${names.join(", ")}]). The artboard name must equal the placement arg.`;
4728
+ }
4717
4729
  var DEFAULT_RENDER_TIMEOUT_MS = 3e4;
4718
4730
  var RenderSession = class {
4719
4731
  svc;
@@ -4823,6 +4835,7 @@ var RenderSession = class {
4823
4835
  */
4824
4836
  async renderState(placement, state, throttleMs = 0) {
4825
4837
  await this.connect();
4838
+ this.warnPlacement(placement, state);
4826
4839
  this.svc.sendCanvasState(placement, state, this.product?.mockupIds.length ?? 1, throttleMs);
4827
4840
  this.armWatchdog();
4828
4841
  }
@@ -4835,6 +4848,7 @@ var RenderSession = class {
4835
4848
  */
4836
4849
  async renderSavedState(placement, stateId) {
4837
4850
  await this.connect();
4851
+ this.warnPlacement(placement);
4838
4852
  this.svc.sendCanvasStateRef(placement, stateId);
4839
4853
  this.armWatchdog();
4840
4854
  }
@@ -4853,6 +4867,28 @@ var RenderSession = class {
4853
4867
  this.svc.disconnect();
4854
4868
  this.ready = null;
4855
4869
  }
4870
+ /**
4871
+ * Emit loud, non-fatal warnings for the two ways a placement name silently
4872
+ * produces a blank mockup: (1) the name isn't one of the product's known
4873
+ * `placements` labels, and (2) no artboard in `state` is named after it. Each
4874
+ * warning goes to BOTH `console.warn` and the `onError` callback (if wired) so
4875
+ * a dev sees it whichever channel they watch. Never throws.
4876
+ */
4877
+ warnPlacement(placement, state) {
4878
+ const warnings = [];
4879
+ if (this.product) {
4880
+ const w = warnUnknownPlacement(this.product, placement);
4881
+ if (w) warnings.push(w);
4882
+ }
4883
+ if (state) {
4884
+ const w = warnPlacementArtboardMismatch(placement, state);
4885
+ if (w) warnings.push(w);
4886
+ }
4887
+ for (const w of warnings) {
4888
+ console.warn(w);
4889
+ this.errorCb?.(w);
4890
+ }
4891
+ }
4856
4892
  /**
4857
4893
  * (Re)arm the render watchdog. Clears any prior timer and, unless disabled
4858
4894
  * (`renderTimeoutMs <= 0`), starts a fresh one. If it fires before a mockup
@@ -4864,11 +4900,32 @@ var RenderSession = class {
4864
4900
  if (this.renderTimeoutMs <= 0) return;
4865
4901
  this.watchdog = setTimeout(() => {
4866
4902
  this.watchdog = null;
4867
- this.errorCb?.(
4868
- `realtime render timed out after ${this.renderTimeoutMs}ms with no mockup \u2014 a product with a color/variant axis needs \`variantId\` in the product config (see RenderProduct.variantId)`
4869
- );
4903
+ this.errorCb?.(this.timeoutMessage());
4870
4904
  }, this.renderTimeoutMs);
4871
4905
  }
4906
+ /**
4907
+ * Build the watchdog timeout message from the *observed* session state rather
4908
+ * than a single hardcoded guess. The old message always blamed a missing
4909
+ * `variantId` — which sent a clean-room dogfood chasing the wrong cause when
4910
+ * the real problem was elsewhere. Report what's actually true: is the socket
4911
+ * open, did config get acknowledged, and only then fall back to the
4912
+ * variant/catalog hypotheses, naming the exact product so it's checkable.
4913
+ */
4914
+ timeoutMessage() {
4915
+ const base = `realtime render timed out after ${this.renderTimeoutMs}ms with no mockup`;
4916
+ const st = this.svc.getState();
4917
+ if (!st.isConnected) {
4918
+ return `${base} \u2014 the WebSocket is not open (it connected, then closed). Check the grant endpoint and network; the session needs a live socket to render.`;
4919
+ }
4920
+ if (!st.isConfigured) {
4921
+ return `${base} \u2014 the server never acknowledged the session config. The socket is open but no \`configured\` message arrived; the render was never accepted.`;
4922
+ }
4923
+ const product = this.product;
4924
+ if (product && variantIdsOf(product).length >= 2 && !product.variantId) {
4925
+ return `${base} \u2014 product ${product.productId} has a color/variant axis but no \`variantId\` was set, so the server is waiting for a variant selection (see RenderProduct.variantId).`;
4926
+ }
4927
+ return `${base} \u2014 the server accepted the config but rendered nothing. Verify the product and mockupIds exist in the catalog (productId="${product?.productId}", mockupIds=[${product?.mockupIds?.join(", ") ?? ""}]) and that the placement name matches an artboard.`;
4928
+ }
4872
4929
  /** Cancel the pending render watchdog, if any. */
4873
4930
  clearWatchdog() {
4874
4931
  if (this.watchdog) {
@@ -4968,7 +5025,12 @@ async function getProduct(idOrSlug, config) {
4968
5025
  const url = `${meilisearchHost}/indexes/${meilisearchIndex}/documents/${idOrSlug}`;
4969
5026
  const res = await f(url, { method: "GET", headers });
4970
5027
  if (res.status === 404)
4971
- throw Object.assign(new Error("Not found"), { code: "NOT_FOUND" });
5028
+ throw Object.assign(
5029
+ new Error(
5030
+ `getProduct: no catalog product "${idOrSlug}" in index "${meilisearchIndex}". Pass a valid product id/slug (browse with listProducts()).`
5031
+ ),
5032
+ { code: "NOT_FOUND", productId: idOrSlug }
5033
+ );
4972
5034
  if (!res.ok) throw new Error(`getProduct failed: ${res.status}`);
4973
5035
  const raw = await res.json();
4974
5036
  return validateProductLoose(raw);
package/dist/react.cjs CHANGED
@@ -34,6 +34,16 @@ var import_react = require("react");
34
34
  var REALTIME_WS_URL = "wss://cdn.snowcone.app/realtime";
35
35
 
36
36
  // src/realtime/websocket.ts
37
+ function randHex(len) {
38
+ const arr = new Uint8Array(len);
39
+ globalThis.crypto.getRandomValues(arr);
40
+ return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
41
+ }
42
+ function newRenderCorrelation() {
43
+ const traceId = randHex(16);
44
+ const spanId = randHex(8);
45
+ return { requestId: traceId, traceparent: `00-${traceId}-${spanId}-01` };
46
+ }
37
47
  var RealtimeMockupService = class {
38
48
  constructor(wsUrl = REALTIME_WS_URL) {
39
49
  this.wsUrl = wsUrl;
@@ -49,8 +59,25 @@ var RealtimeMockupService = class {
49
59
  lastError = null;
50
60
  callbacks = {};
51
61
  lastBlobSentAt = 0;
62
+ // Per-request send timestamps keyed by requestId, for client-observed
63
+ // round-trip timing in the mockup_rendered log. Pruned to a small cap so an
64
+ // in-flight burst can't grow it unbounded.
65
+ requestSendTimes = /* @__PURE__ */ new Map();
52
66
  canvasBlobs = /* @__PURE__ */ new Map();
53
67
  canvasStates = /* @__PURE__ */ new Map();
68
+ // States sent BEFORE the session is connected+configured. The #1 recurring DX
69
+ // failure ("connect race"): a caller fires renderState/sendCanvasState during
70
+ // the WS handshake, the state was silently dropped, and the 30s watchdog fired
71
+ // with no mockup pointing at the wrong cause. Blobs were already buffered and
72
+ // auto-flushed on `configured`; states were not. We now buffer the latest
73
+ // state per placement and flush it on `configured`, so the first render
74
+ // survives the race on EVERY path through this service — the `RenderSession`
75
+ // facade and the `useRealtimeMockup` hook alike.
76
+ pendingStates = /* @__PURE__ */ new Map();
77
+ // Serialized JSON of the last canvas state ACTUALLY SENT per placement. Used to
78
+ // drop duplicate consecutive states so an idle canvas (whose onChange keeps
79
+ // firing identical states) doesn't stream renders and burn the realtime budget.
80
+ lastSentStateJson = {};
54
81
  colors = /* @__PURE__ */ new Map();
55
82
  lastSendTime = {};
56
83
  throttleTimeouts = {};
@@ -71,6 +98,8 @@ var RealtimeMockupService = class {
71
98
  // the WS with `?token=`, and renews ~15s before expiry via a `renew` message.
72
99
  tokenProvider;
73
100
  renewTimer = null;
101
+ isConnecting = false;
102
+ connectAttempt = 0;
74
103
  setCallbacks(callbacks) {
75
104
  this.callbacks = callbacks;
76
105
  }
@@ -97,16 +126,21 @@ var RealtimeMockupService = class {
97
126
  this.callbacks.onLog?.(message, type);
98
127
  }
99
128
  connect() {
100
- if (this.ws?.readyState === WebSocket.OPEN) {
129
+ if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING || this.isConnecting) {
101
130
  return;
102
131
  }
132
+ const attempt = ++this.connectAttempt;
133
+ this.isConnecting = true;
103
134
  if (this.tokenProvider) {
104
135
  this.tokenProvider().then((grant) => {
136
+ if (attempt !== this.connectAttempt || !this.isConnecting) return;
105
137
  this.openSocket(grant.token);
106
138
  this.scheduleRenew(grant.expiresAt);
107
139
  }).catch((err) => {
140
+ if (attempt !== this.connectAttempt) return;
108
141
  this.addLog(`Failed to obtain realtime grant: ${err}`);
109
142
  this.status = "Disconnected";
143
+ this.isConnecting = false;
110
144
  });
111
145
  return;
112
146
  }
@@ -120,6 +154,7 @@ var RealtimeMockupService = class {
120
154
  console.log(`[WS] connection OPENED to ${this.wsUrl}`);
121
155
  this.addLog("WebSocket connection opened");
122
156
  this.status = "Connected";
157
+ this.isConnecting = false;
123
158
  };
124
159
  this.ws.onmessage = (event) => {
125
160
  try {
@@ -138,11 +173,13 @@ var RealtimeMockupService = class {
138
173
  this.isConfigured = false;
139
174
  this.configSent = false;
140
175
  this.clearRenew();
176
+ this.isConnecting = false;
141
177
  this.callbacks.onDisconnected?.();
142
178
  };
143
179
  this.ws.onerror = (error) => {
144
180
  this.addLog(`WebSocket error: ${error}`);
145
181
  this.status = "Disconnected";
182
+ this.isConnecting = false;
146
183
  };
147
184
  }
148
185
  scheduleRenew(expiresAt) {
@@ -205,6 +242,16 @@ var RealtimeMockupService = class {
205
242
  });
206
243
  }, 100);
207
244
  }
245
+ if (this.pendingStates.size > 0) {
246
+ const pending = Array.from(this.pendingStates.entries());
247
+ this.pendingStates.clear();
248
+ this.addLog(`\u{1F4E6} Flushing ${pending.length} buffered canvas state(s)`);
249
+ setTimeout(() => {
250
+ for (const [placement, { state, mockupCount }] of pending) {
251
+ this.sendCanvasState(placement, state, mockupCount, 0);
252
+ }
253
+ }, 100);
254
+ }
208
255
  break;
209
256
  case "blob_received":
210
257
  const placementName = data.placement || "unknown";
@@ -218,8 +265,10 @@ var RealtimeMockupService = class {
218
265
  case "rendering_started":
219
266
  this.addLog("\u{1F3A8} Mockup rendering has started...");
220
267
  break;
221
- case "mockup_rendered":
222
- console.log(`[WS] mockup_rendered received: mockupId=${data.mockupId} hasImageUrl=${!!data.imageUrl} v=${data.requestVersion} placement="${data.placement}" renderMs=${data.renderMs}`);
268
+ case "mockup_rendered": {
269
+ const sentAt = data.requestId ? this.requestSendTimes.get(data.requestId) : void 0;
270
+ const clientRoundTripMs = sentAt !== void 0 ? Date.now() - sentAt : void 0;
271
+ console.log(`[WS] mockup_rendered received: mockupId=${data.mockupId} hasImageUrl=${!!data.imageUrl} v=${data.requestVersion} req=${data.requestId ?? "?"} placement="${data.placement}" renderMs=${data.renderMs} blobToRenderMs=${data.blobToRenderMs} clientRoundTripMs=${clientRoundTripMs ?? "?"} at ${Date.now()}`);
223
272
  if (data.imageUrl && data.mockupId) {
224
273
  const responseVersion = data.requestVersion;
225
274
  const responsePlacement = data.placement;
@@ -237,6 +286,7 @@ var RealtimeMockupService = class {
237
286
  imageUrl: data.imageUrl,
238
287
  renderUrl: data.renderUrl || data.imageUrl,
239
288
  imageSize: data.imageSize || 0,
289
+ requestId: data.requestId,
240
290
  requestVersion: responseVersion,
241
291
  placement: responsePlacement,
242
292
  renderMs: data.renderMs,
@@ -259,6 +309,7 @@ var RealtimeMockupService = class {
259
309
  this.addLog(`\u26A0\uFE0F mockup_rendered message dropped: missing required fields [${missing.join(", ")}]. Full data keys: [${Object.keys(data).join(", ")}]`);
260
310
  }
261
311
  break;
312
+ }
262
313
  case "all_mockups_rendered":
263
314
  console.log(`[WS] all_mockups_rendered received: ${data.mockups?.length ?? 0} mockups`);
264
315
  if (data.mockups) {
@@ -292,7 +343,10 @@ var RealtimeMockupService = class {
292
343
  }
293
344
  }
294
345
  disconnect() {
346
+ this.connectAttempt++;
347
+ this.isConnecting = false;
295
348
  this.clearRenew();
349
+ this.pendingStates.clear();
296
350
  if (this.ws) {
297
351
  this.ws.close();
298
352
  this.ws = null;
@@ -330,6 +384,7 @@ var RealtimeMockupService = class {
330
384
  this.canvasStates.clear();
331
385
  this.colors.clear();
332
386
  this.lastSendTime = {};
387
+ this.lastSentStateJson = {};
333
388
  Object.values(this.throttleTimeouts).forEach((timeout) => clearTimeout(timeout));
334
389
  this.throttleTimeouts = {};
335
390
  this.requestVersion = 0;
@@ -463,9 +518,11 @@ var RealtimeMockupService = class {
463
518
  sendCanvasState(placement, state, mockupCount = 1, baseThrottleMs = 1e3) {
464
519
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.isConfigured) {
465
520
  const reason = !this.ws ? "no WebSocket" : this.ws.readyState !== WebSocket.OPEN ? `ws.readyState=${this.ws.readyState}` : "not configured";
466
- console.log(`[WS] sendCanvasState BLOCKED for "${placement}": ${reason}`);
467
- return false;
521
+ this.pendingStates.set(placement, { state, mockupCount, baseThrottleMs });
522
+ console.log(`[WS] sendCanvasState buffered for "${placement}" until configured: ${reason}`);
523
+ return true;
468
524
  }
525
+ this.pendingStates.delete(placement);
469
526
  this.canvasStates.set(placement, state);
470
527
  if (baseThrottleMs <= 0) {
471
528
  this.sendCanvasStateImmediately(placement, state);
@@ -518,34 +575,58 @@ var RealtimeMockupService = class {
518
575
  }
519
576
  this.lastSentVersion = ++this.requestVersion;
520
577
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
578
+ const { requestId, traceparent } = newRenderCorrelation();
521
579
  const message = JSON.stringify({
522
580
  type: "canvas_state",
523
581
  placement,
524
582
  version: this.lastSentVersion,
583
+ requestId,
584
+ traceparent,
525
585
  stateId
526
586
  });
527
587
  this.ws.send(message);
588
+ this.recordRequestSent(requestId);
528
589
  this.lastBlobSentAt = Date.now();
529
- this.addLog(`Sent canvas stateId "${stateId}" for "${placement}" (v${this.lastSentVersion})`, "sent");
590
+ console.log(`[WS] canvas_state(ref) SENT: placement="${placement}" v${this.lastSentVersion} req=${requestId} stateId=${stateId} at ${Date.now()}`);
591
+ this.addLog(`Sent canvas stateId "${stateId}" for "${placement}" (v${this.lastSentVersion}, req ${requestId})`, "sent");
530
592
  this.callbacks.onBlobSent?.(placement);
531
593
  return true;
532
594
  }
595
+ /** Stamp a request's send time for client round-trip measurement (capped). */
596
+ recordRequestSent(requestId) {
597
+ this.requestSendTimes.set(requestId, Date.now());
598
+ if (this.requestSendTimes.size > 50) {
599
+ const oldest = this.requestSendTimes.keys().next().value;
600
+ if (oldest !== void 0) this.requestSendTimes.delete(oldest);
601
+ }
602
+ }
533
603
  sendCanvasStateImmediately(placement, state) {
534
604
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
535
605
  console.log(`[WS] sendCanvasStateImmediately ABORTED: ws not open`);
536
606
  return;
537
607
  }
608
+ const stateJson = JSON.stringify(state);
609
+ if (this.lastSentStateJson[placement] === stateJson) {
610
+ this.addLog(`Skipped duplicate canvas state for "${placement}" (unchanged)`, "sent");
611
+ return;
612
+ }
538
613
  this.lastSentVersion = ++this.requestVersion;
539
614
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
615
+ const { requestId, traceparent } = newRenderCorrelation();
540
616
  const message = JSON.stringify({
541
617
  type: "canvas_state",
542
618
  placement,
543
619
  version: this.lastSentVersion,
620
+ requestId,
621
+ traceparent,
544
622
  state
545
623
  });
546
624
  this.ws.send(message);
625
+ this.recordRequestSent(requestId);
626
+ this.lastSentStateJson[placement] = stateJson;
547
627
  this.lastBlobSentAt = Date.now();
548
- this.addLog(`Sent canvas state for "${placement}" (${(message.length / 1024).toFixed(1)}KB, v${this.lastSentVersion})`, "sent");
628
+ console.log(`[WS] canvas_state SENT: placement="${placement}" v${this.lastSentVersion} req=${requestId} (${(message.length / 1024).toFixed(1)}KB) at ${Date.now()}`);
629
+ this.addLog(`Sent canvas state for "${placement}" (${(message.length / 1024).toFixed(1)}KB, v${this.lastSentVersion}, req ${requestId})`, "sent");
549
630
  this.callbacks.onBlobSent?.(placement);
550
631
  }
551
632
  sendBlobImmediately(placement, blob, notifyCallback = true) {
package/dist/react.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { M as MockupResult, W as WebSocketConfig, P as PlacementSettings, F as FrameworkAdapter, b as ComponentProps, d as ComponentState, e as ComponentContext, f as ComponentLifecycleHooks, E as EventHandler, c as ComponentDescriptor, g as FrameworkUtilities } from './websocket-Poy8LZNA.cjs';
1
+ import { M as MockupResult, W as WebSocketConfig, P as PlacementSettings, F as FrameworkAdapter, b as ComponentProps, d as ComponentState, e as ComponentContext, f as ComponentLifecycleHooks, E as EventHandler, c as ComponentDescriptor, g as FrameworkUtilities } from './websocket-hXpNcyEn.cjs';
2
2
 
3
3
  interface UseRealtimeMockupOptions {
4
4
  wsUrl?: string;
package/dist/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { M as MockupResult, W as WebSocketConfig, P as PlacementSettings, F as FrameworkAdapter, b as ComponentProps, d as ComponentState, e as ComponentContext, f as ComponentLifecycleHooks, E as EventHandler, c as ComponentDescriptor, g as FrameworkUtilities } from './websocket-Poy8LZNA.js';
1
+ import { M as MockupResult, W as WebSocketConfig, P as PlacementSettings, F as FrameworkAdapter, b as ComponentProps, d as ComponentState, e as ComponentContext, f as ComponentLifecycleHooks, E as EventHandler, c as ComponentDescriptor, g as FrameworkUtilities } from './websocket-hXpNcyEn.js';
2
2
 
3
3
  interface UseRealtimeMockupOptions {
4
4
  wsUrl?: string;
package/dist/react.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  RealtimeMockupService
3
- } from "./chunk-D5ZRGKA5.js";
3
+ } from "./chunk-YTOTCNEW.js";
4
4
 
5
5
  // src/realtime/react.ts
6
6
  import { useCallback, useEffect, useRef, useState } from "react";
@@ -17,9 +17,10 @@ declare function resolveBestCombination(selection: OptionSelection, attributes:
17
17
  */
18
18
  declare function computeDisabledChoices(selection: OptionSelection, attributes: Record<string, OptionAttribute>, combinations: Combination[]): Record<string, string[]>;
19
19
  /**
20
- * Derives a reasonable default selection based on the best combination.
20
+ * Derives a complete default selection based on the best combination.
21
21
  * - For attributes that affect combinations, choose the value from the best combo.
22
- * - For non-affecting attributes, leave unset (caller may choose first choice if desired).
22
+ * - For every remaining attribute, fall back to a default (color pickers get
23
+ * black; otherwise the first choice) so the returned selection is complete.
23
24
  */
24
25
  declare function deriveDefaultSelection(attributes: Record<string, OptionAttribute>, combinations: Combination[]): OptionSelection;
25
26
  /**
@@ -229,6 +230,8 @@ interface MockupResult {
229
230
  imageUrl: string;
230
231
  renderUrl: string;
231
232
  imageSize: number;
233
+ /** Per-render correlation id (== OTel trace-id) echoed back by the server */
234
+ requestId?: string;
232
235
  /** The request version this mockup corresponds to (for stale detection) */
233
236
  requestVersion?: number;
234
237
  /** The placement this mockup corresponds to */
@@ -251,6 +254,8 @@ interface WebSocketMessage {
251
254
  message?: string;
252
255
  placement?: string;
253
256
  missingPlacements?: string[];
257
+ /** Per-render correlation id (== OTel trace-id) echoed back by the server */
258
+ requestId?: string;
254
259
  /** The request version this response corresponds to (for stale detection) */
255
260
  requestVersion?: number;
256
261
  /** Server-side PIXI render time in ms */
@@ -304,8 +309,11 @@ declare class RealtimeMockupService {
304
309
  private lastError;
305
310
  private callbacks;
306
311
  private lastBlobSentAt;
312
+ private requestSendTimes;
307
313
  private canvasBlobs;
308
314
  private canvasStates;
315
+ private pendingStates;
316
+ private lastSentStateJson;
309
317
  private colors;
310
318
  private lastSendTime;
311
319
  private throttleTimeouts;
@@ -316,6 +324,8 @@ declare class RealtimeMockupService {
316
324
  private sendVersionInBlob;
317
325
  private tokenProvider?;
318
326
  private renewTimer;
327
+ private isConnecting;
328
+ private connectAttempt;
319
329
  constructor(wsUrl?: string);
320
330
  setCallbacks(callbacks: RealtimeMockupCallbacks): void;
321
331
  /** Provide a grant fetcher to authorize the session (per-shop, renewable). */
@@ -363,6 +373,8 @@ declare class RealtimeMockupService {
363
373
  * canvas JSON. One-shot (no throttle). Results arrive like any other render.
364
374
  */
365
375
  sendCanvasStateRef(placement: string, stateId: string): boolean;
376
+ /** Stamp a request's send time for client round-trip measurement (capped). */
377
+ private recordRequestSent;
366
378
  private sendCanvasStateImmediately;
367
379
  private sendBlobImmediately;
368
380
  /**
@@ -17,9 +17,10 @@ declare function resolveBestCombination(selection: OptionSelection, attributes:
17
17
  */
18
18
  declare function computeDisabledChoices(selection: OptionSelection, attributes: Record<string, OptionAttribute>, combinations: Combination[]): Record<string, string[]>;
19
19
  /**
20
- * Derives a reasonable default selection based on the best combination.
20
+ * Derives a complete default selection based on the best combination.
21
21
  * - For attributes that affect combinations, choose the value from the best combo.
22
- * - For non-affecting attributes, leave unset (caller may choose first choice if desired).
22
+ * - For every remaining attribute, fall back to a default (color pickers get
23
+ * black; otherwise the first choice) so the returned selection is complete.
23
24
  */
24
25
  declare function deriveDefaultSelection(attributes: Record<string, OptionAttribute>, combinations: Combination[]): OptionSelection;
25
26
  /**
@@ -229,6 +230,8 @@ interface MockupResult {
229
230
  imageUrl: string;
230
231
  renderUrl: string;
231
232
  imageSize: number;
233
+ /** Per-render correlation id (== OTel trace-id) echoed back by the server */
234
+ requestId?: string;
232
235
  /** The request version this mockup corresponds to (for stale detection) */
233
236
  requestVersion?: number;
234
237
  /** The placement this mockup corresponds to */
@@ -251,6 +254,8 @@ interface WebSocketMessage {
251
254
  message?: string;
252
255
  placement?: string;
253
256
  missingPlacements?: string[];
257
+ /** Per-render correlation id (== OTel trace-id) echoed back by the server */
258
+ requestId?: string;
254
259
  /** The request version this response corresponds to (for stale detection) */
255
260
  requestVersion?: number;
256
261
  /** Server-side PIXI render time in ms */
@@ -304,8 +309,11 @@ declare class RealtimeMockupService {
304
309
  private lastError;
305
310
  private callbacks;
306
311
  private lastBlobSentAt;
312
+ private requestSendTimes;
307
313
  private canvasBlobs;
308
314
  private canvasStates;
315
+ private pendingStates;
316
+ private lastSentStateJson;
309
317
  private colors;
310
318
  private lastSendTime;
311
319
  private throttleTimeouts;
@@ -316,6 +324,8 @@ declare class RealtimeMockupService {
316
324
  private sendVersionInBlob;
317
325
  private tokenProvider?;
318
326
  private renewTimer;
327
+ private isConnecting;
328
+ private connectAttempt;
319
329
  constructor(wsUrl?: string);
320
330
  setCallbacks(callbacks: RealtimeMockupCallbacks): void;
321
331
  /** Provide a grant fetcher to authorize the session (per-shop, renewable). */
@@ -363,6 +373,8 @@ declare class RealtimeMockupService {
363
373
  * canvas JSON. One-shot (no throttle). Results arrive like any other render.
364
374
  */
365
375
  sendCanvasStateRef(placement: string, stateId: string): boolean;
376
+ /** Stamp a request's send time for client round-trip measurement (capped). */
377
+ private recordRequestSent;
366
378
  private sendCanvasStateImmediately;
367
379
  private sendBlobImmediately;
368
380
  /**