@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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.5
4
+
5
+ ### Patch Changes
6
+
7
+ - [#304](https://github.com/snowcone-app/snowcone-monorepo/pull/304) [`2700cd9`](https://github.com/snowcone-app/snowcone-monorepo/commit/2700cd93831c50a1a5d496b04d40fd0c1a89b641) Thanks [@kevinsproles](https://github.com/kevinsproles)! - Realtime: the first render no longer races the WebSocket handshake. `sendCanvasState` (and therefore both `RenderSession.renderState` and the `useRealtimeMockup` hook) now **buffers** a canvas state sent before the session is connected+configured and flushes it automatically on `configured` — exactly as canvas blobs already did. Previously the state was silently dropped (`ws.readyState=0 BLOCKED`) and dead-ended in the 30s render watchdog with no mockup. This was the most-reported clean-room DX failure; it is now impossible on every code path through `RealtimeMockupService`.
8
+
9
+ The render-timeout message is also now **cause-aware**: instead of always blaming a missing `variantId`, it reports what was actually observed — socket not open, config never acknowledged, a genuine missing variant selection, or "the server accepted config but rendered nothing" (naming the product/mockupIds to check). The old message sent dogfooders chasing the wrong cause.
10
+
11
+ Pinned by regression tests: pre-connect buffering + flush at the service level, and a React **StrictMode** mount of `useRealtimeMockup` proving the first render survives the mount→cleanup→mount double-invoke that App-Router projects trigger.
12
+
13
+ - [#309](https://github.com/snowcone-app/snowcone-monorepo/pull/309) [`df2793c`](https://github.com/snowcone-app/snowcone-monorepo/commit/df2793c915bc8dc43eb0948a5f74912b333a43d2) Thanks [@kevinsproles](https://github.com/kevinsproles)! - Realtime: every `canvas_state` render now carries a per-request **`requestId`** (a 32-hex W3C trace-id) and a **`traceparent`** header so a single edit can be followed end-to-end. The client logs `req=<id>` on send and on `mockup_rendered` (alongside `renderMs`, `blobToRenderMs`, and a client-observed `clientRoundTripMs`), the server tags the queue → dequeue → composite-fetch start/end log lines with the same `req=<id>`, and the server forwards the `traceparent` onto the internal `/mockups` composite render so it becomes a child span in OTel. Grep one id across client + server logs, or paste it as a trace-id in the tracing backend, to localize where realtime render latency is spent. `requestId` is now also surfaced on `MockupResult`.
14
+
15
+ ## 0.3.4
16
+
17
+ ### Patch Changes
18
+
19
+ - [#288](https://github.com/snowcone-app/snowcone-monorepo/pull/288) [`7ccfe17`](https://github.com/snowcone-app/snowcone-monorepo/commit/7ccfe1724b166719e2aac4e0f5e9a2a5c2c440a4) Thanks [@kevinsproles](https://github.com/kevinsproles)! - `getProduct()` now throws an actionable error for an unknown product id/slug (naming the id, the index, and pointing to `listProducts()`) instead of a bare "Not found", so a typo'd catalog id no longer surfaces as the raw Meilisearch `document_not_found` shape.
20
+
21
+ - [#281](https://github.com/snowcone-app/snowcone-monorepo/pull/281) [`9e3d941`](https://github.com/snowcone-app/snowcone-monorepo/commit/9e3d941419b3df004feb47369692d30a4a8a2938) Thanks [@kevinsproles](https://github.com/kevinsproles)! - Coalesce duplicate realtime `connect()` calls while a grant is pending or the WebSocket is still connecting, preventing competing initial sockets and benign 1006 close noise during first render.
22
+
23
+ - [#293](https://github.com/snowcone-app/snowcone-monorepo/pull/293) [`64bcaf0`](https://github.com/snowcone-app/snowcone-monorepo/commit/64bcaf0b61dcd611a81d9a9cb013ab89dc8003f7) Thanks [@kevinsproles](https://github.com/kevinsproles)! - Stop idle canvases from continuously streaming server renders (which silently burned the realtime render budget). `serializeStateForServer` now quantizes element/artboard geometry to 0.01px so the render loop's sub-pixel float drift no longer produces a new wire state every frame, and `RealtimeMockupService` drops duplicate consecutive canvas states per placement. A naive `onChange → renderState` wiring no longer bills the integrator's realtime quota while the user is doing nothing.
24
+
25
+ - [#300](https://github.com/snowcone-app/snowcone-monorepo/pull/300) [`f80f00a`](https://github.com/snowcone-app/snowcone-monorepo/commit/f80f00a0e38631aac936bfe60de14e7d47be3e6a) Thanks [@kevinsproles](https://github.com/kevinsproles)! - `mockupUrl()` now resolves the shop id and mockup base URL by reading `process.env.NEXT_PUBLIC_*` directly instead of through an aliased `const env = process.env`. The alias defeated Next/webpack's static inlining, so in browser bundles the values came back `undefined` and the live-preview URL fell through to the `SHOP_NOT_CONFIGURED` placeholder. Consumers relying on `NEXT_PUBLIC_SNOWCONE_SHOP_ID` / `NEXT_PUBLIC_MERCH_MOCKUP_URL` env fallbacks (rather than the explicit `shop` prop / `window.snowcone`) now get the configured values.
26
+
3
27
  ## 0.3.3
4
28
 
5
29
  ### Patch Changes
@@ -2,6 +2,16 @@
2
2
  var REALTIME_WS_URL = "wss://cdn.snowcone.app/realtime";
3
3
 
4
4
  // src/realtime/websocket.ts
5
+ function randHex(len) {
6
+ const arr = new Uint8Array(len);
7
+ globalThis.crypto.getRandomValues(arr);
8
+ return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
9
+ }
10
+ function newRenderCorrelation() {
11
+ const traceId = randHex(16);
12
+ const spanId = randHex(8);
13
+ return { requestId: traceId, traceparent: `00-${traceId}-${spanId}-01` };
14
+ }
5
15
  var RealtimeMockupService = class {
6
16
  constructor(wsUrl = REALTIME_WS_URL) {
7
17
  this.wsUrl = wsUrl;
@@ -17,8 +27,25 @@ var RealtimeMockupService = class {
17
27
  lastError = null;
18
28
  callbacks = {};
19
29
  lastBlobSentAt = 0;
30
+ // Per-request send timestamps keyed by requestId, for client-observed
31
+ // round-trip timing in the mockup_rendered log. Pruned to a small cap so an
32
+ // in-flight burst can't grow it unbounded.
33
+ requestSendTimes = /* @__PURE__ */ new Map();
20
34
  canvasBlobs = /* @__PURE__ */ new Map();
21
35
  canvasStates = /* @__PURE__ */ new Map();
36
+ // States sent BEFORE the session is connected+configured. The #1 recurring DX
37
+ // failure ("connect race"): a caller fires renderState/sendCanvasState during
38
+ // the WS handshake, the state was silently dropped, and the 30s watchdog fired
39
+ // with no mockup pointing at the wrong cause. Blobs were already buffered and
40
+ // auto-flushed on `configured`; states were not. We now buffer the latest
41
+ // state per placement and flush it on `configured`, so the first render
42
+ // survives the race on EVERY path through this service — the `RenderSession`
43
+ // facade and the `useRealtimeMockup` hook alike.
44
+ pendingStates = /* @__PURE__ */ new Map();
45
+ // Serialized JSON of the last canvas state ACTUALLY SENT per placement. Used to
46
+ // drop duplicate consecutive states so an idle canvas (whose onChange keeps
47
+ // firing identical states) doesn't stream renders and burn the realtime budget.
48
+ lastSentStateJson = {};
22
49
  colors = /* @__PURE__ */ new Map();
23
50
  lastSendTime = {};
24
51
  throttleTimeouts = {};
@@ -39,6 +66,8 @@ var RealtimeMockupService = class {
39
66
  // the WS with `?token=`, and renews ~15s before expiry via a `renew` message.
40
67
  tokenProvider;
41
68
  renewTimer = null;
69
+ isConnecting = false;
70
+ connectAttempt = 0;
42
71
  setCallbacks(callbacks) {
43
72
  this.callbacks = callbacks;
44
73
  }
@@ -65,16 +94,21 @@ var RealtimeMockupService = class {
65
94
  this.callbacks.onLog?.(message, type);
66
95
  }
67
96
  connect() {
68
- if (this.ws?.readyState === WebSocket.OPEN) {
97
+ if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING || this.isConnecting) {
69
98
  return;
70
99
  }
100
+ const attempt = ++this.connectAttempt;
101
+ this.isConnecting = true;
71
102
  if (this.tokenProvider) {
72
103
  this.tokenProvider().then((grant) => {
104
+ if (attempt !== this.connectAttempt || !this.isConnecting) return;
73
105
  this.openSocket(grant.token);
74
106
  this.scheduleRenew(grant.expiresAt);
75
107
  }).catch((err) => {
108
+ if (attempt !== this.connectAttempt) return;
76
109
  this.addLog(`Failed to obtain realtime grant: ${err}`);
77
110
  this.status = "Disconnected";
111
+ this.isConnecting = false;
78
112
  });
79
113
  return;
80
114
  }
@@ -88,6 +122,7 @@ var RealtimeMockupService = class {
88
122
  console.log(`[WS] connection OPENED to ${this.wsUrl}`);
89
123
  this.addLog("WebSocket connection opened");
90
124
  this.status = "Connected";
125
+ this.isConnecting = false;
91
126
  };
92
127
  this.ws.onmessage = (event) => {
93
128
  try {
@@ -106,11 +141,13 @@ var RealtimeMockupService = class {
106
141
  this.isConfigured = false;
107
142
  this.configSent = false;
108
143
  this.clearRenew();
144
+ this.isConnecting = false;
109
145
  this.callbacks.onDisconnected?.();
110
146
  };
111
147
  this.ws.onerror = (error) => {
112
148
  this.addLog(`WebSocket error: ${error}`);
113
149
  this.status = "Disconnected";
150
+ this.isConnecting = false;
114
151
  };
115
152
  }
116
153
  scheduleRenew(expiresAt) {
@@ -173,6 +210,16 @@ var RealtimeMockupService = class {
173
210
  });
174
211
  }, 100);
175
212
  }
213
+ if (this.pendingStates.size > 0) {
214
+ const pending = Array.from(this.pendingStates.entries());
215
+ this.pendingStates.clear();
216
+ this.addLog(`\u{1F4E6} Flushing ${pending.length} buffered canvas state(s)`);
217
+ setTimeout(() => {
218
+ for (const [placement, { state, mockupCount }] of pending) {
219
+ this.sendCanvasState(placement, state, mockupCount, 0);
220
+ }
221
+ }, 100);
222
+ }
176
223
  break;
177
224
  case "blob_received":
178
225
  const placementName = data.placement || "unknown";
@@ -186,8 +233,10 @@ var RealtimeMockupService = class {
186
233
  case "rendering_started":
187
234
  this.addLog("\u{1F3A8} Mockup rendering has started...");
188
235
  break;
189
- case "mockup_rendered":
190
- console.log(`[WS] mockup_rendered received: mockupId=${data.mockupId} hasImageUrl=${!!data.imageUrl} v=${data.requestVersion} placement="${data.placement}" renderMs=${data.renderMs}`);
236
+ case "mockup_rendered": {
237
+ const sentAt = data.requestId ? this.requestSendTimes.get(data.requestId) : void 0;
238
+ const clientRoundTripMs = sentAt !== void 0 ? Date.now() - sentAt : void 0;
239
+ 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()}`);
191
240
  if (data.imageUrl && data.mockupId) {
192
241
  const responseVersion = data.requestVersion;
193
242
  const responsePlacement = data.placement;
@@ -205,6 +254,7 @@ var RealtimeMockupService = class {
205
254
  imageUrl: data.imageUrl,
206
255
  renderUrl: data.renderUrl || data.imageUrl,
207
256
  imageSize: data.imageSize || 0,
257
+ requestId: data.requestId,
208
258
  requestVersion: responseVersion,
209
259
  placement: responsePlacement,
210
260
  renderMs: data.renderMs,
@@ -227,6 +277,7 @@ var RealtimeMockupService = class {
227
277
  this.addLog(`\u26A0\uFE0F mockup_rendered message dropped: missing required fields [${missing.join(", ")}]. Full data keys: [${Object.keys(data).join(", ")}]`);
228
278
  }
229
279
  break;
280
+ }
230
281
  case "all_mockups_rendered":
231
282
  console.log(`[WS] all_mockups_rendered received: ${data.mockups?.length ?? 0} mockups`);
232
283
  if (data.mockups) {
@@ -260,7 +311,10 @@ var RealtimeMockupService = class {
260
311
  }
261
312
  }
262
313
  disconnect() {
314
+ this.connectAttempt++;
315
+ this.isConnecting = false;
263
316
  this.clearRenew();
317
+ this.pendingStates.clear();
264
318
  if (this.ws) {
265
319
  this.ws.close();
266
320
  this.ws = null;
@@ -298,6 +352,7 @@ var RealtimeMockupService = class {
298
352
  this.canvasStates.clear();
299
353
  this.colors.clear();
300
354
  this.lastSendTime = {};
355
+ this.lastSentStateJson = {};
301
356
  Object.values(this.throttleTimeouts).forEach((timeout) => clearTimeout(timeout));
302
357
  this.throttleTimeouts = {};
303
358
  this.requestVersion = 0;
@@ -431,9 +486,11 @@ var RealtimeMockupService = class {
431
486
  sendCanvasState(placement, state, mockupCount = 1, baseThrottleMs = 1e3) {
432
487
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.isConfigured) {
433
488
  const reason = !this.ws ? "no WebSocket" : this.ws.readyState !== WebSocket.OPEN ? `ws.readyState=${this.ws.readyState}` : "not configured";
434
- console.log(`[WS] sendCanvasState BLOCKED for "${placement}": ${reason}`);
435
- return false;
489
+ this.pendingStates.set(placement, { state, mockupCount, baseThrottleMs });
490
+ console.log(`[WS] sendCanvasState buffered for "${placement}" until configured: ${reason}`);
491
+ return true;
436
492
  }
493
+ this.pendingStates.delete(placement);
437
494
  this.canvasStates.set(placement, state);
438
495
  if (baseThrottleMs <= 0) {
439
496
  this.sendCanvasStateImmediately(placement, state);
@@ -486,34 +543,58 @@ var RealtimeMockupService = class {
486
543
  }
487
544
  this.lastSentVersion = ++this.requestVersion;
488
545
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
546
+ const { requestId, traceparent } = newRenderCorrelation();
489
547
  const message = JSON.stringify({
490
548
  type: "canvas_state",
491
549
  placement,
492
550
  version: this.lastSentVersion,
551
+ requestId,
552
+ traceparent,
493
553
  stateId
494
554
  });
495
555
  this.ws.send(message);
556
+ this.recordRequestSent(requestId);
496
557
  this.lastBlobSentAt = Date.now();
497
- this.addLog(`Sent canvas stateId "${stateId}" for "${placement}" (v${this.lastSentVersion})`, "sent");
558
+ console.log(`[WS] canvas_state(ref) SENT: placement="${placement}" v${this.lastSentVersion} req=${requestId} stateId=${stateId} at ${Date.now()}`);
559
+ this.addLog(`Sent canvas stateId "${stateId}" for "${placement}" (v${this.lastSentVersion}, req ${requestId})`, "sent");
498
560
  this.callbacks.onBlobSent?.(placement);
499
561
  return true;
500
562
  }
563
+ /** Stamp a request's send time for client round-trip measurement (capped). */
564
+ recordRequestSent(requestId) {
565
+ this.requestSendTimes.set(requestId, Date.now());
566
+ if (this.requestSendTimes.size > 50) {
567
+ const oldest = this.requestSendTimes.keys().next().value;
568
+ if (oldest !== void 0) this.requestSendTimes.delete(oldest);
569
+ }
570
+ }
501
571
  sendCanvasStateImmediately(placement, state) {
502
572
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
503
573
  console.log(`[WS] sendCanvasStateImmediately ABORTED: ws not open`);
504
574
  return;
505
575
  }
576
+ const stateJson = JSON.stringify(state);
577
+ if (this.lastSentStateJson[placement] === stateJson) {
578
+ this.addLog(`Skipped duplicate canvas state for "${placement}" (unchanged)`, "sent");
579
+ return;
580
+ }
506
581
  this.lastSentVersion = ++this.requestVersion;
507
582
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
583
+ const { requestId, traceparent } = newRenderCorrelation();
508
584
  const message = JSON.stringify({
509
585
  type: "canvas_state",
510
586
  placement,
511
587
  version: this.lastSentVersion,
588
+ requestId,
589
+ traceparent,
512
590
  state
513
591
  });
514
592
  this.ws.send(message);
593
+ this.recordRequestSent(requestId);
594
+ this.lastSentStateJson[placement] = stateJson;
515
595
  this.lastBlobSentAt = Date.now();
516
- this.addLog(`Sent canvas state for "${placement}" (${(message.length / 1024).toFixed(1)}KB, v${this.lastSentVersion})`, "sent");
596
+ console.log(`[WS] canvas_state SENT: placement="${placement}" v${this.lastSentVersion} req=${requestId} (${(message.length / 1024).toFixed(1)}KB) at ${Date.now()}`);
597
+ this.addLog(`Sent canvas state for "${placement}" (${(message.length / 1024).toFixed(1)}KB, v${this.lastSentVersion}, req ${requestId})`, "sent");
517
598
  this.callbacks.onBlobSent?.(placement);
518
599
  }
519
600
  sendBlobImmediately(placement, blob, notifyCallback = true) {
package/dist/index.cjs CHANGED
@@ -1797,14 +1797,12 @@ function buildMockupUrl2(options, cfg) {
1797
1797
  );
1798
1798
  }
1799
1799
  function resolveMockupBaseUrl() {
1800
- const env = typeof process !== "undefined" ? process.env : void 0;
1801
1800
  const winConfig = typeof window !== "undefined" && window.snowcone || {};
1802
- return winConfig.mockupUrl || env?.SNOWCONE_IMAGE_URL || env?.NEXT_PUBLIC_MERCH_MOCKUP_URL || "https://cdn.snowcone.app";
1801
+ 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";
1803
1802
  }
1804
1803
  function resolveShop() {
1805
- const env = typeof process !== "undefined" ? process.env : void 0;
1806
1804
  const winConfig = typeof window !== "undefined" && window.snowcone || {};
1807
- return winConfig.shop || env?.SNOWCONE_SHOP_ID || env?.NEXT_PUBLIC_SNOWCONE_SHOP_ID || "SHOP_NOT_CONFIGURED";
1805
+ return winConfig.shop || typeof process !== "undefined" && process.env?.SNOWCONE_SHOP_ID || typeof process !== "undefined" && process.env?.NEXT_PUBLIC_SNOWCONE_SHOP_ID || "SHOP_NOT_CONFIGURED";
1808
1806
  }
1809
1807
  function mockupUrl(options) {
1810
1808
  const mockupBaseUrl = resolveMockupBaseUrl();
@@ -4820,6 +4818,16 @@ function createSvelteComponent(descriptor, svelte) {
4820
4818
  var REALTIME_WS_URL = "wss://cdn.snowcone.app/realtime";
4821
4819
 
4822
4820
  // src/realtime/websocket.ts
4821
+ function randHex(len) {
4822
+ const arr = new Uint8Array(len);
4823
+ globalThis.crypto.getRandomValues(arr);
4824
+ return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
4825
+ }
4826
+ function newRenderCorrelation() {
4827
+ const traceId = randHex(16);
4828
+ const spanId = randHex(8);
4829
+ return { requestId: traceId, traceparent: `00-${traceId}-${spanId}-01` };
4830
+ }
4823
4831
  var RealtimeMockupService = class {
4824
4832
  constructor(wsUrl = REALTIME_WS_URL) {
4825
4833
  this.wsUrl = wsUrl;
@@ -4835,8 +4843,25 @@ var RealtimeMockupService = class {
4835
4843
  lastError = null;
4836
4844
  callbacks = {};
4837
4845
  lastBlobSentAt = 0;
4846
+ // Per-request send timestamps keyed by requestId, for client-observed
4847
+ // round-trip timing in the mockup_rendered log. Pruned to a small cap so an
4848
+ // in-flight burst can't grow it unbounded.
4849
+ requestSendTimes = /* @__PURE__ */ new Map();
4838
4850
  canvasBlobs = /* @__PURE__ */ new Map();
4839
4851
  canvasStates = /* @__PURE__ */ new Map();
4852
+ // States sent BEFORE the session is connected+configured. The #1 recurring DX
4853
+ // failure ("connect race"): a caller fires renderState/sendCanvasState during
4854
+ // the WS handshake, the state was silently dropped, and the 30s watchdog fired
4855
+ // with no mockup pointing at the wrong cause. Blobs were already buffered and
4856
+ // auto-flushed on `configured`; states were not. We now buffer the latest
4857
+ // state per placement and flush it on `configured`, so the first render
4858
+ // survives the race on EVERY path through this service — the `RenderSession`
4859
+ // facade and the `useRealtimeMockup` hook alike.
4860
+ pendingStates = /* @__PURE__ */ new Map();
4861
+ // Serialized JSON of the last canvas state ACTUALLY SENT per placement. Used to
4862
+ // drop duplicate consecutive states so an idle canvas (whose onChange keeps
4863
+ // firing identical states) doesn't stream renders and burn the realtime budget.
4864
+ lastSentStateJson = {};
4840
4865
  colors = /* @__PURE__ */ new Map();
4841
4866
  lastSendTime = {};
4842
4867
  throttleTimeouts = {};
@@ -4857,6 +4882,8 @@ var RealtimeMockupService = class {
4857
4882
  // the WS with `?token=`, and renews ~15s before expiry via a `renew` message.
4858
4883
  tokenProvider;
4859
4884
  renewTimer = null;
4885
+ isConnecting = false;
4886
+ connectAttempt = 0;
4860
4887
  setCallbacks(callbacks) {
4861
4888
  this.callbacks = callbacks;
4862
4889
  }
@@ -4883,16 +4910,21 @@ var RealtimeMockupService = class {
4883
4910
  this.callbacks.onLog?.(message, type);
4884
4911
  }
4885
4912
  connect() {
4886
- if (this.ws?.readyState === WebSocket.OPEN) {
4913
+ if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING || this.isConnecting) {
4887
4914
  return;
4888
4915
  }
4916
+ const attempt = ++this.connectAttempt;
4917
+ this.isConnecting = true;
4889
4918
  if (this.tokenProvider) {
4890
4919
  this.tokenProvider().then((grant) => {
4920
+ if (attempt !== this.connectAttempt || !this.isConnecting) return;
4891
4921
  this.openSocket(grant.token);
4892
4922
  this.scheduleRenew(grant.expiresAt);
4893
4923
  }).catch((err) => {
4924
+ if (attempt !== this.connectAttempt) return;
4894
4925
  this.addLog(`Failed to obtain realtime grant: ${err}`);
4895
4926
  this.status = "Disconnected";
4927
+ this.isConnecting = false;
4896
4928
  });
4897
4929
  return;
4898
4930
  }
@@ -4906,6 +4938,7 @@ var RealtimeMockupService = class {
4906
4938
  console.log(`[WS] connection OPENED to ${this.wsUrl}`);
4907
4939
  this.addLog("WebSocket connection opened");
4908
4940
  this.status = "Connected";
4941
+ this.isConnecting = false;
4909
4942
  };
4910
4943
  this.ws.onmessage = (event) => {
4911
4944
  try {
@@ -4924,11 +4957,13 @@ var RealtimeMockupService = class {
4924
4957
  this.isConfigured = false;
4925
4958
  this.configSent = false;
4926
4959
  this.clearRenew();
4960
+ this.isConnecting = false;
4927
4961
  this.callbacks.onDisconnected?.();
4928
4962
  };
4929
4963
  this.ws.onerror = (error) => {
4930
4964
  this.addLog(`WebSocket error: ${error}`);
4931
4965
  this.status = "Disconnected";
4966
+ this.isConnecting = false;
4932
4967
  };
4933
4968
  }
4934
4969
  scheduleRenew(expiresAt) {
@@ -4991,6 +5026,16 @@ var RealtimeMockupService = class {
4991
5026
  });
4992
5027
  }, 100);
4993
5028
  }
5029
+ if (this.pendingStates.size > 0) {
5030
+ const pending = Array.from(this.pendingStates.entries());
5031
+ this.pendingStates.clear();
5032
+ this.addLog(`\u{1F4E6} Flushing ${pending.length} buffered canvas state(s)`);
5033
+ setTimeout(() => {
5034
+ for (const [placement, { state, mockupCount }] of pending) {
5035
+ this.sendCanvasState(placement, state, mockupCount, 0);
5036
+ }
5037
+ }, 100);
5038
+ }
4994
5039
  break;
4995
5040
  case "blob_received":
4996
5041
  const placementName = data.placement || "unknown";
@@ -5004,8 +5049,10 @@ var RealtimeMockupService = class {
5004
5049
  case "rendering_started":
5005
5050
  this.addLog("\u{1F3A8} Mockup rendering has started...");
5006
5051
  break;
5007
- case "mockup_rendered":
5008
- console.log(`[WS] mockup_rendered received: mockupId=${data.mockupId} hasImageUrl=${!!data.imageUrl} v=${data.requestVersion} placement="${data.placement}" renderMs=${data.renderMs}`);
5052
+ case "mockup_rendered": {
5053
+ const sentAt = data.requestId ? this.requestSendTimes.get(data.requestId) : void 0;
5054
+ const clientRoundTripMs = sentAt !== void 0 ? Date.now() - sentAt : void 0;
5055
+ 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()}`);
5009
5056
  if (data.imageUrl && data.mockupId) {
5010
5057
  const responseVersion = data.requestVersion;
5011
5058
  const responsePlacement = data.placement;
@@ -5023,6 +5070,7 @@ var RealtimeMockupService = class {
5023
5070
  imageUrl: data.imageUrl,
5024
5071
  renderUrl: data.renderUrl || data.imageUrl,
5025
5072
  imageSize: data.imageSize || 0,
5073
+ requestId: data.requestId,
5026
5074
  requestVersion: responseVersion,
5027
5075
  placement: responsePlacement,
5028
5076
  renderMs: data.renderMs,
@@ -5045,6 +5093,7 @@ var RealtimeMockupService = class {
5045
5093
  this.addLog(`\u26A0\uFE0F mockup_rendered message dropped: missing required fields [${missing.join(", ")}]. Full data keys: [${Object.keys(data).join(", ")}]`);
5046
5094
  }
5047
5095
  break;
5096
+ }
5048
5097
  case "all_mockups_rendered":
5049
5098
  console.log(`[WS] all_mockups_rendered received: ${data.mockups?.length ?? 0} mockups`);
5050
5099
  if (data.mockups) {
@@ -5078,7 +5127,10 @@ var RealtimeMockupService = class {
5078
5127
  }
5079
5128
  }
5080
5129
  disconnect() {
5130
+ this.connectAttempt++;
5131
+ this.isConnecting = false;
5081
5132
  this.clearRenew();
5133
+ this.pendingStates.clear();
5082
5134
  if (this.ws) {
5083
5135
  this.ws.close();
5084
5136
  this.ws = null;
@@ -5116,6 +5168,7 @@ var RealtimeMockupService = class {
5116
5168
  this.canvasStates.clear();
5117
5169
  this.colors.clear();
5118
5170
  this.lastSendTime = {};
5171
+ this.lastSentStateJson = {};
5119
5172
  Object.values(this.throttleTimeouts).forEach((timeout) => clearTimeout(timeout));
5120
5173
  this.throttleTimeouts = {};
5121
5174
  this.requestVersion = 0;
@@ -5249,9 +5302,11 @@ var RealtimeMockupService = class {
5249
5302
  sendCanvasState(placement, state, mockupCount = 1, baseThrottleMs = 1e3) {
5250
5303
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.isConfigured) {
5251
5304
  const reason = !this.ws ? "no WebSocket" : this.ws.readyState !== WebSocket.OPEN ? `ws.readyState=${this.ws.readyState}` : "not configured";
5252
- console.log(`[WS] sendCanvasState BLOCKED for "${placement}": ${reason}`);
5253
- return false;
5305
+ this.pendingStates.set(placement, { state, mockupCount, baseThrottleMs });
5306
+ console.log(`[WS] sendCanvasState buffered for "${placement}" until configured: ${reason}`);
5307
+ return true;
5254
5308
  }
5309
+ this.pendingStates.delete(placement);
5255
5310
  this.canvasStates.set(placement, state);
5256
5311
  if (baseThrottleMs <= 0) {
5257
5312
  this.sendCanvasStateImmediately(placement, state);
@@ -5304,34 +5359,58 @@ var RealtimeMockupService = class {
5304
5359
  }
5305
5360
  this.lastSentVersion = ++this.requestVersion;
5306
5361
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
5362
+ const { requestId, traceparent } = newRenderCorrelation();
5307
5363
  const message = JSON.stringify({
5308
5364
  type: "canvas_state",
5309
5365
  placement,
5310
5366
  version: this.lastSentVersion,
5367
+ requestId,
5368
+ traceparent,
5311
5369
  stateId
5312
5370
  });
5313
5371
  this.ws.send(message);
5372
+ this.recordRequestSent(requestId);
5314
5373
  this.lastBlobSentAt = Date.now();
5315
- this.addLog(`Sent canvas stateId "${stateId}" for "${placement}" (v${this.lastSentVersion})`, "sent");
5374
+ console.log(`[WS] canvas_state(ref) SENT: placement="${placement}" v${this.lastSentVersion} req=${requestId} stateId=${stateId} at ${Date.now()}`);
5375
+ this.addLog(`Sent canvas stateId "${stateId}" for "${placement}" (v${this.lastSentVersion}, req ${requestId})`, "sent");
5316
5376
  this.callbacks.onBlobSent?.(placement);
5317
5377
  return true;
5318
5378
  }
5379
+ /** Stamp a request's send time for client round-trip measurement (capped). */
5380
+ recordRequestSent(requestId) {
5381
+ this.requestSendTimes.set(requestId, Date.now());
5382
+ if (this.requestSendTimes.size > 50) {
5383
+ const oldest = this.requestSendTimes.keys().next().value;
5384
+ if (oldest !== void 0) this.requestSendTimes.delete(oldest);
5385
+ }
5386
+ }
5319
5387
  sendCanvasStateImmediately(placement, state) {
5320
5388
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
5321
5389
  console.log(`[WS] sendCanvasStateImmediately ABORTED: ws not open`);
5322
5390
  return;
5323
5391
  }
5392
+ const stateJson = JSON.stringify(state);
5393
+ if (this.lastSentStateJson[placement] === stateJson) {
5394
+ this.addLog(`Skipped duplicate canvas state for "${placement}" (unchanged)`, "sent");
5395
+ return;
5396
+ }
5324
5397
  this.lastSentVersion = ++this.requestVersion;
5325
5398
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
5399
+ const { requestId, traceparent } = newRenderCorrelation();
5326
5400
  const message = JSON.stringify({
5327
5401
  type: "canvas_state",
5328
5402
  placement,
5329
5403
  version: this.lastSentVersion,
5404
+ requestId,
5405
+ traceparent,
5330
5406
  state
5331
5407
  });
5332
5408
  this.ws.send(message);
5409
+ this.recordRequestSent(requestId);
5410
+ this.lastSentStateJson[placement] = stateJson;
5333
5411
  this.lastBlobSentAt = Date.now();
5334
- this.addLog(`Sent canvas state for "${placement}" (${(message.length / 1024).toFixed(1)}KB, v${this.lastSentVersion})`, "sent");
5412
+ console.log(`[WS] canvas_state SENT: placement="${placement}" v${this.lastSentVersion} req=${requestId} (${(message.length / 1024).toFixed(1)}KB) at ${Date.now()}`);
5413
+ this.addLog(`Sent canvas state for "${placement}" (${(message.length / 1024).toFixed(1)}KB, v${this.lastSentVersion}, req ${requestId})`, "sent");
5335
5414
  this.callbacks.onBlobSent?.(placement);
5336
5415
  }
5337
5416
  sendBlobImmediately(placement, blob, notifyCallback = true) {
@@ -5548,6 +5627,20 @@ function assertVariantSelected(product) {
5548
5627
  );
5549
5628
  }
5550
5629
  }
5630
+ function warnUnknownPlacement(product, placement) {
5631
+ const placements = product.placements;
5632
+ if (!placements?.length) return null;
5633
+ if (placements.includes(placement)) return null;
5634
+ 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.`;
5635
+ }
5636
+ function warnPlacementArtboardMismatch(placement, state) {
5637
+ const artboards = state.artboards;
5638
+ if (!Array.isArray(artboards) || artboards.length === 0) return null;
5639
+ const names = artboards.map((a) => a.name).filter((n) => typeof n === "string");
5640
+ if (names.length === 0) return null;
5641
+ if (names.includes(placement)) return null;
5642
+ return `RenderSession: renderState placement "${placement}" has no matching artboard in the state (artboards: [${names.join(", ")}]). The artboard name must equal the placement arg.`;
5643
+ }
5551
5644
  var DEFAULT_RENDER_TIMEOUT_MS = 3e4;
5552
5645
  var RenderSession = class {
5553
5646
  svc;
@@ -5657,6 +5750,7 @@ var RenderSession = class {
5657
5750
  */
5658
5751
  async renderState(placement, state, throttleMs = 0) {
5659
5752
  await this.connect();
5753
+ this.warnPlacement(placement, state);
5660
5754
  this.svc.sendCanvasState(placement, state, this.product?.mockupIds.length ?? 1, throttleMs);
5661
5755
  this.armWatchdog();
5662
5756
  }
@@ -5669,6 +5763,7 @@ var RenderSession = class {
5669
5763
  */
5670
5764
  async renderSavedState(placement, stateId) {
5671
5765
  await this.connect();
5766
+ this.warnPlacement(placement);
5672
5767
  this.svc.sendCanvasStateRef(placement, stateId);
5673
5768
  this.armWatchdog();
5674
5769
  }
@@ -5687,6 +5782,28 @@ var RenderSession = class {
5687
5782
  this.svc.disconnect();
5688
5783
  this.ready = null;
5689
5784
  }
5785
+ /**
5786
+ * Emit loud, non-fatal warnings for the two ways a placement name silently
5787
+ * produces a blank mockup: (1) the name isn't one of the product's known
5788
+ * `placements` labels, and (2) no artboard in `state` is named after it. Each
5789
+ * warning goes to BOTH `console.warn` and the `onError` callback (if wired) so
5790
+ * a dev sees it whichever channel they watch. Never throws.
5791
+ */
5792
+ warnPlacement(placement, state) {
5793
+ const warnings = [];
5794
+ if (this.product) {
5795
+ const w = warnUnknownPlacement(this.product, placement);
5796
+ if (w) warnings.push(w);
5797
+ }
5798
+ if (state) {
5799
+ const w = warnPlacementArtboardMismatch(placement, state);
5800
+ if (w) warnings.push(w);
5801
+ }
5802
+ for (const w of warnings) {
5803
+ console.warn(w);
5804
+ this.errorCb?.(w);
5805
+ }
5806
+ }
5690
5807
  /**
5691
5808
  * (Re)arm the render watchdog. Clears any prior timer and, unless disabled
5692
5809
  * (`renderTimeoutMs <= 0`), starts a fresh one. If it fires before a mockup
@@ -5698,11 +5815,32 @@ var RenderSession = class {
5698
5815
  if (this.renderTimeoutMs <= 0) return;
5699
5816
  this.watchdog = setTimeout(() => {
5700
5817
  this.watchdog = null;
5701
- this.errorCb?.(
5702
- `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)`
5703
- );
5818
+ this.errorCb?.(this.timeoutMessage());
5704
5819
  }, this.renderTimeoutMs);
5705
5820
  }
5821
+ /**
5822
+ * Build the watchdog timeout message from the *observed* session state rather
5823
+ * than a single hardcoded guess. The old message always blamed a missing
5824
+ * `variantId` — which sent a clean-room dogfood chasing the wrong cause when
5825
+ * the real problem was elsewhere. Report what's actually true: is the socket
5826
+ * open, did config get acknowledged, and only then fall back to the
5827
+ * variant/catalog hypotheses, naming the exact product so it's checkable.
5828
+ */
5829
+ timeoutMessage() {
5830
+ const base = `realtime render timed out after ${this.renderTimeoutMs}ms with no mockup`;
5831
+ const st = this.svc.getState();
5832
+ if (!st.isConnected) {
5833
+ 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.`;
5834
+ }
5835
+ if (!st.isConfigured) {
5836
+ return `${base} \u2014 the server never acknowledged the session config. The socket is open but no \`configured\` message arrived; the render was never accepted.`;
5837
+ }
5838
+ const product = this.product;
5839
+ if (product && variantIdsOf(product).length >= 2 && !product.variantId) {
5840
+ 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).`;
5841
+ }
5842
+ 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.`;
5843
+ }
5706
5844
  /** Cancel the pending render watchdog, if any. */
5707
5845
  clearWatchdog() {
5708
5846
  if (this.watchdog) {
@@ -5802,7 +5940,12 @@ async function getProduct(idOrSlug, config) {
5802
5940
  const url = `${meilisearchHost}/indexes/${meilisearchIndex}/documents/${idOrSlug}`;
5803
5941
  const res = await f(url, { method: "GET", headers });
5804
5942
  if (res.status === 404)
5805
- throw Object.assign(new Error("Not found"), { code: "NOT_FOUND" });
5943
+ throw Object.assign(
5944
+ new Error(
5945
+ `getProduct: no catalog product "${idOrSlug}" in index "${meilisearchIndex}". Pass a valid product id/slug (browse with listProducts()).`
5946
+ ),
5947
+ { code: "NOT_FOUND", productId: idOrSlug }
5948
+ );
5806
5949
  if (!res.ok) throw new Error(`getProduct failed: ${res.status}`);
5807
5950
  const raw = await res.json();
5808
5951
  return validateProductLoose(raw);