@snowcone-app/sdk 0.3.4 → 0.3.6

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,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.6
4
+
5
+ ### Patch Changes
6
+
7
+ - [#317](https://github.com/snowcone-app/snowcone-monorepo/pull/317) [`ef0f08e`](https://github.com/snowcone-app/snowcone-monorepo/commit/ef0f08ee3ffa14b1db3a4011e4a0f7e5e42340a1) Thanks [@kevinsproles](https://github.com/kevinsproles)! - Republish to ship two already-landed source fixes that never reached npm:
8
+ - **ui:** the `./package.json` export (added in #279) is in source but the live
9
+ `0.1.30` tarball still omits it, so `@snowcone-app/ui/package.json` resolution
10
+ throws `ERR_PACKAGE_PATH_NOT_EXPORTED` for anyone installing today. This bump
11
+ republishes with it.
12
+ - **sdk:** carries the corrected `RenderSession` `mockupIds` examples (JSDoc +
13
+ README) out to npm — they showed a placement name where opaque catalog scene
14
+ codes (`mockups[].id`, e.g. `FV1qjO`) belong.
15
+
16
+ - [#322](https://github.com/snowcone-app/snowcone-monorepo/pull/322) [`4017a11`](https://github.com/snowcone-app/snowcone-monorepo/commit/4017a11385d3eab3cc1b86f27842e2f783e1f628) Thanks [@kevinsproles](https://github.com/kevinsproles)! - Fix client-side `mockupUrl()` base resolution: prefer the resolver
17
+ (`window.snowcone.resolver` / `NEXT_PUBLIC_MOCKUP_RESOLVER_URL` /
18
+ `SNOWCONE_IMAGE_URL`, default `img.snowcone.app`) and never fall back to the
19
+ renderer. `ShopProvider` publishes the renderer as `window.snowcone.mockupUrl`,
20
+ which `resolveMockupBaseUrl()` previously read first — so in the browser every
21
+ `mockupUrl()` built the resolver grammar against the renderer host and 400'd
22
+ with "required property 'seal'". (The prior fix only corrected the server path,
23
+ where `window.snowcone` is absent.)
24
+
25
+ - [#321](https://github.com/snowcone-app/snowcone-monorepo/pull/321) [`3f693ac`](https://github.com/snowcone-app/snowcone-monorepo/commit/3f693ac8cd28405ffd7e14781432a2f43ddf2dc9) Thanks [@kevinsproles](https://github.com/kevinsproles)! - Fix `mockupUrl()` base host: it builds the public-image **resolver** grammar
26
+ (`/{code}?asset=&shop=…`, unsigned), so its base must be the resolver
27
+ (`img.snowcone.app`), which seals the URL and 302s to the renderer. It was
28
+ defaulting to the **renderer** (`cdn.snowcone.app`), which rejects unsigned
29
+ URLs with `400 … querystring must have required property 'seal'`. Now defaults
30
+ to `img.snowcone.app` (matching `getMockupUrl`) and prefers
31
+ `NEXT_PUBLIC_MOCKUP_RESOLVER_URL`.
32
+
33
+ ## 0.3.5
34
+
35
+ ### Patch Changes
36
+
37
+ - [#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`.
38
+
39
+ 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.
40
+
41
+ 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.
42
+
43
+ - [#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`.
44
+
3
45
  ## 0.3.4
4
46
 
5
47
  ### Patch Changes
package/README.md CHANGED
@@ -204,7 +204,9 @@ import { RenderSession } from '@snowcone-app/sdk';
204
204
  const session = new RenderSession({
205
205
  shop: 'YOUR_SHOP_ID', // publishable, like Stripe pk_
206
206
  grantUrl: '/api/realtime/grant', // your proxy above
207
- product: { productId: 'BEEB77', mockupIds: ['Front'] },
207
+ // mockupIds are catalog SCENE CODES (product.mockups[].id) — not placement
208
+ // names. variantId is required for products with a color/size option.
209
+ product: { productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC' },
208
210
  });
209
211
 
210
212
  session.onMockups((results) => { img.src = results[0].imageUrl; });
@@ -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,21 @@ 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();
22
45
  // Serialized JSON of the last canvas state ACTUALLY SENT per placement. Used to
23
46
  // drop duplicate consecutive states so an idle canvas (whose onChange keeps
24
47
  // firing identical states) doesn't stream renders and burn the realtime budget.
@@ -187,6 +210,16 @@ var RealtimeMockupService = class {
187
210
  });
188
211
  }, 100);
189
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
+ }
190
223
  break;
191
224
  case "blob_received":
192
225
  const placementName = data.placement || "unknown";
@@ -200,8 +233,10 @@ var RealtimeMockupService = class {
200
233
  case "rendering_started":
201
234
  this.addLog("\u{1F3A8} Mockup rendering has started...");
202
235
  break;
203
- case "mockup_rendered":
204
- 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()}`);
205
240
  if (data.imageUrl && data.mockupId) {
206
241
  const responseVersion = data.requestVersion;
207
242
  const responsePlacement = data.placement;
@@ -219,6 +254,7 @@ var RealtimeMockupService = class {
219
254
  imageUrl: data.imageUrl,
220
255
  renderUrl: data.renderUrl || data.imageUrl,
221
256
  imageSize: data.imageSize || 0,
257
+ requestId: data.requestId,
222
258
  requestVersion: responseVersion,
223
259
  placement: responsePlacement,
224
260
  renderMs: data.renderMs,
@@ -241,6 +277,7 @@ var RealtimeMockupService = class {
241
277
  this.addLog(`\u26A0\uFE0F mockup_rendered message dropped: missing required fields [${missing.join(", ")}]. Full data keys: [${Object.keys(data).join(", ")}]`);
242
278
  }
243
279
  break;
280
+ }
244
281
  case "all_mockups_rendered":
245
282
  console.log(`[WS] all_mockups_rendered received: ${data.mockups?.length ?? 0} mockups`);
246
283
  if (data.mockups) {
@@ -277,6 +314,7 @@ var RealtimeMockupService = class {
277
314
  this.connectAttempt++;
278
315
  this.isConnecting = false;
279
316
  this.clearRenew();
317
+ this.pendingStates.clear();
280
318
  if (this.ws) {
281
319
  this.ws.close();
282
320
  this.ws = null;
@@ -448,9 +486,11 @@ var RealtimeMockupService = class {
448
486
  sendCanvasState(placement, state, mockupCount = 1, baseThrottleMs = 1e3) {
449
487
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.isConfigured) {
450
488
  const reason = !this.ws ? "no WebSocket" : this.ws.readyState !== WebSocket.OPEN ? `ws.readyState=${this.ws.readyState}` : "not configured";
451
- console.log(`[WS] sendCanvasState BLOCKED for "${placement}": ${reason}`);
452
- return false;
489
+ this.pendingStates.set(placement, { state, mockupCount, baseThrottleMs });
490
+ console.log(`[WS] sendCanvasState buffered for "${placement}" until configured: ${reason}`);
491
+ return true;
453
492
  }
493
+ this.pendingStates.delete(placement);
454
494
  this.canvasStates.set(placement, state);
455
495
  if (baseThrottleMs <= 0) {
456
496
  this.sendCanvasStateImmediately(placement, state);
@@ -503,18 +543,31 @@ var RealtimeMockupService = class {
503
543
  }
504
544
  this.lastSentVersion = ++this.requestVersion;
505
545
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
546
+ const { requestId, traceparent } = newRenderCorrelation();
506
547
  const message = JSON.stringify({
507
548
  type: "canvas_state",
508
549
  placement,
509
550
  version: this.lastSentVersion,
551
+ requestId,
552
+ traceparent,
510
553
  stateId
511
554
  });
512
555
  this.ws.send(message);
556
+ this.recordRequestSent(requestId);
513
557
  this.lastBlobSentAt = Date.now();
514
- 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");
515
560
  this.callbacks.onBlobSent?.(placement);
516
561
  return true;
517
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
+ }
518
571
  sendCanvasStateImmediately(placement, state) {
519
572
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
520
573
  console.log(`[WS] sendCanvasStateImmediately ABORTED: ws not open`);
@@ -527,16 +580,21 @@ var RealtimeMockupService = class {
527
580
  }
528
581
  this.lastSentVersion = ++this.requestVersion;
529
582
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
583
+ const { requestId, traceparent } = newRenderCorrelation();
530
584
  const message = JSON.stringify({
531
585
  type: "canvas_state",
532
586
  placement,
533
587
  version: this.lastSentVersion,
588
+ requestId,
589
+ traceparent,
534
590
  state
535
591
  });
536
592
  this.ws.send(message);
593
+ this.recordRequestSent(requestId);
537
594
  this.lastSentStateJson[placement] = stateJson;
538
595
  this.lastBlobSentAt = Date.now();
539
- 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");
540
598
  this.callbacks.onBlobSent?.(placement);
541
599
  }
542
600
  sendBlobImmediately(placement, blob, notifyCallback = true) {
package/dist/index.cjs CHANGED
@@ -1798,7 +1798,7 @@ function buildMockupUrl2(options, cfg) {
1798
1798
  }
1799
1799
  function resolveMockupBaseUrl() {
1800
1800
  const winConfig = typeof window !== "undefined" && window.snowcone || {};
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";
1801
+ return winConfig.resolver || typeof process !== "undefined" && process.env?.NEXT_PUBLIC_MOCKUP_RESOLVER_URL || typeof process !== "undefined" && process.env?.SNOWCONE_IMAGE_URL || "https://img.snowcone.app";
1802
1802
  }
1803
1803
  function resolveShop() {
1804
1804
  const winConfig = typeof window !== "undefined" && window.snowcone || {};
@@ -4818,6 +4818,16 @@ function createSvelteComponent(descriptor, svelte) {
4818
4818
  var REALTIME_WS_URL = "wss://cdn.snowcone.app/realtime";
4819
4819
 
4820
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
+ }
4821
4831
  var RealtimeMockupService = class {
4822
4832
  constructor(wsUrl = REALTIME_WS_URL) {
4823
4833
  this.wsUrl = wsUrl;
@@ -4833,8 +4843,21 @@ var RealtimeMockupService = class {
4833
4843
  lastError = null;
4834
4844
  callbacks = {};
4835
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();
4836
4850
  canvasBlobs = /* @__PURE__ */ new Map();
4837
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();
4838
4861
  // Serialized JSON of the last canvas state ACTUALLY SENT per placement. Used to
4839
4862
  // drop duplicate consecutive states so an idle canvas (whose onChange keeps
4840
4863
  // firing identical states) doesn't stream renders and burn the realtime budget.
@@ -5003,6 +5026,16 @@ var RealtimeMockupService = class {
5003
5026
  });
5004
5027
  }, 100);
5005
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
+ }
5006
5039
  break;
5007
5040
  case "blob_received":
5008
5041
  const placementName = data.placement || "unknown";
@@ -5016,8 +5049,10 @@ var RealtimeMockupService = class {
5016
5049
  case "rendering_started":
5017
5050
  this.addLog("\u{1F3A8} Mockup rendering has started...");
5018
5051
  break;
5019
- case "mockup_rendered":
5020
- 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()}`);
5021
5056
  if (data.imageUrl && data.mockupId) {
5022
5057
  const responseVersion = data.requestVersion;
5023
5058
  const responsePlacement = data.placement;
@@ -5035,6 +5070,7 @@ var RealtimeMockupService = class {
5035
5070
  imageUrl: data.imageUrl,
5036
5071
  renderUrl: data.renderUrl || data.imageUrl,
5037
5072
  imageSize: data.imageSize || 0,
5073
+ requestId: data.requestId,
5038
5074
  requestVersion: responseVersion,
5039
5075
  placement: responsePlacement,
5040
5076
  renderMs: data.renderMs,
@@ -5057,6 +5093,7 @@ var RealtimeMockupService = class {
5057
5093
  this.addLog(`\u26A0\uFE0F mockup_rendered message dropped: missing required fields [${missing.join(", ")}]. Full data keys: [${Object.keys(data).join(", ")}]`);
5058
5094
  }
5059
5095
  break;
5096
+ }
5060
5097
  case "all_mockups_rendered":
5061
5098
  console.log(`[WS] all_mockups_rendered received: ${data.mockups?.length ?? 0} mockups`);
5062
5099
  if (data.mockups) {
@@ -5093,6 +5130,7 @@ var RealtimeMockupService = class {
5093
5130
  this.connectAttempt++;
5094
5131
  this.isConnecting = false;
5095
5132
  this.clearRenew();
5133
+ this.pendingStates.clear();
5096
5134
  if (this.ws) {
5097
5135
  this.ws.close();
5098
5136
  this.ws = null;
@@ -5264,9 +5302,11 @@ var RealtimeMockupService = class {
5264
5302
  sendCanvasState(placement, state, mockupCount = 1, baseThrottleMs = 1e3) {
5265
5303
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.isConfigured) {
5266
5304
  const reason = !this.ws ? "no WebSocket" : this.ws.readyState !== WebSocket.OPEN ? `ws.readyState=${this.ws.readyState}` : "not configured";
5267
- console.log(`[WS] sendCanvasState BLOCKED for "${placement}": ${reason}`);
5268
- return false;
5305
+ this.pendingStates.set(placement, { state, mockupCount, baseThrottleMs });
5306
+ console.log(`[WS] sendCanvasState buffered for "${placement}" until configured: ${reason}`);
5307
+ return true;
5269
5308
  }
5309
+ this.pendingStates.delete(placement);
5270
5310
  this.canvasStates.set(placement, state);
5271
5311
  if (baseThrottleMs <= 0) {
5272
5312
  this.sendCanvasStateImmediately(placement, state);
@@ -5319,18 +5359,31 @@ var RealtimeMockupService = class {
5319
5359
  }
5320
5360
  this.lastSentVersion = ++this.requestVersion;
5321
5361
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
5362
+ const { requestId, traceparent } = newRenderCorrelation();
5322
5363
  const message = JSON.stringify({
5323
5364
  type: "canvas_state",
5324
5365
  placement,
5325
5366
  version: this.lastSentVersion,
5367
+ requestId,
5368
+ traceparent,
5326
5369
  stateId
5327
5370
  });
5328
5371
  this.ws.send(message);
5372
+ this.recordRequestSent(requestId);
5329
5373
  this.lastBlobSentAt = Date.now();
5330
- 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");
5331
5376
  this.callbacks.onBlobSent?.(placement);
5332
5377
  return true;
5333
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
+ }
5334
5387
  sendCanvasStateImmediately(placement, state) {
5335
5388
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
5336
5389
  console.log(`[WS] sendCanvasStateImmediately ABORTED: ws not open`);
@@ -5343,16 +5396,21 @@ var RealtimeMockupService = class {
5343
5396
  }
5344
5397
  this.lastSentVersion = ++this.requestVersion;
5345
5398
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
5399
+ const { requestId, traceparent } = newRenderCorrelation();
5346
5400
  const message = JSON.stringify({
5347
5401
  type: "canvas_state",
5348
5402
  placement,
5349
5403
  version: this.lastSentVersion,
5404
+ requestId,
5405
+ traceparent,
5350
5406
  state
5351
5407
  });
5352
5408
  this.ws.send(message);
5409
+ this.recordRequestSent(requestId);
5353
5410
  this.lastSentStateJson[placement] = stateJson;
5354
5411
  this.lastBlobSentAt = Date.now();
5355
- 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");
5356
5414
  this.callbacks.onBlobSent?.(placement);
5357
5415
  }
5358
5416
  sendBlobImmediately(placement, blob, notifyCallback = true) {
@@ -5757,11 +5815,32 @@ var RenderSession = class {
5757
5815
  if (this.renderTimeoutMs <= 0) return;
5758
5816
  this.watchdog = setTimeout(() => {
5759
5817
  this.watchdog = null;
5760
- this.errorCb?.(
5761
- `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)`
5762
- );
5818
+ this.errorCb?.(this.timeoutMessage());
5763
5819
  }, this.renderTimeoutMs);
5764
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
+ }
5765
5844
  /** Cancel the pending render watchdog, if any. */
5766
5845
  clearWatchdog() {
5767
5846
  if (this.watchdog) {
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-DTRStLRH.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-DTRStLRH.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;
@@ -820,7 +820,8 @@ type UserSelection = Record<string, string>;
820
820
  * Config for the pure URL builder: the two things not on MockupGenerationOptions.
821
821
  */
822
822
  interface BuildMockupUrlConfig {
823
- /** Base of the renderer/CDN, e.g. https://cdn.snowcone.app */
823
+ /** Base of the public-image RESOLVER, e.g. https://img.snowcone.app (it
824
+ * seals the URL and 302s to the renderer). NOT the renderer host. */
824
825
  mockupBaseUrl: string;
825
826
  /** The shop's public Shop ID (= shop.id). */
826
827
  shop: string;
@@ -2581,9 +2582,13 @@ declare function fetchRealtimeGrant(grantUrl: string, shop: string, fetchImpl?:
2581
2582
  * const session = new RenderSession({
2582
2583
  * shop: 'YOUR_SHOP_ID',
2583
2584
  * grantUrl: '/api/realtime/grant', // your proxy → mintRealtimeGrant (server)
2584
- * product: { productId: 'BEEB77', mockupIds: ['front'] },
2585
+ * // mockupIds are opaque catalog SCENE CODES (product.mockups[].id) — NOT
2586
+ * // placement names. variantId is required for products with a color/size axis.
2587
+ * product: { productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC' },
2585
2588
  * });
2586
2589
  * session.onMockups((results) => { img.src = results[0].imageUrl; });
2590
+ * // First arg is the PLACEMENT name (placements[].label, e.g. 'Front') — a
2591
+ * // different id space from mockupIds, and it must match a canvas artboard.
2587
2592
  * await session.renderState('Front', serializeStateForServer(canvasState));
2588
2593
  *
2589
2594
  * For the low-level escape hatch, use {@link RealtimeMockupService} directly.
@@ -2780,6 +2785,15 @@ declare class RenderSession {
2780
2785
  * server/transport errors — with a hint at the most common cause.
2781
2786
  */
2782
2787
  private armWatchdog;
2788
+ /**
2789
+ * Build the watchdog timeout message from the *observed* session state rather
2790
+ * than a single hardcoded guess. The old message always blamed a missing
2791
+ * `variantId` — which sent a clean-room dogfood chasing the wrong cause when
2792
+ * the real problem was elsewhere. Report what's actually true: is the socket
2793
+ * open, did config get acknowledged, and only then fall back to the
2794
+ * variant/catalog hypotheses, naming the exact product so it's checkable.
2795
+ */
2796
+ private timeoutMessage;
2783
2797
  /** Cancel the pending render watchdog, if any. */
2784
2798
  private clearWatchdog;
2785
2799
  /** 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-DTRStLRH.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-DTRStLRH.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;
@@ -820,7 +820,8 @@ type UserSelection = Record<string, string>;
820
820
  * Config for the pure URL builder: the two things not on MockupGenerationOptions.
821
821
  */
822
822
  interface BuildMockupUrlConfig {
823
- /** Base of the renderer/CDN, e.g. https://cdn.snowcone.app */
823
+ /** Base of the public-image RESOLVER, e.g. https://img.snowcone.app (it
824
+ * seals the URL and 302s to the renderer). NOT the renderer host. */
824
825
  mockupBaseUrl: string;
825
826
  /** The shop's public Shop ID (= shop.id). */
826
827
  shop: string;
@@ -2581,9 +2582,13 @@ declare function fetchRealtimeGrant(grantUrl: string, shop: string, fetchImpl?:
2581
2582
  * const session = new RenderSession({
2582
2583
  * shop: 'YOUR_SHOP_ID',
2583
2584
  * grantUrl: '/api/realtime/grant', // your proxy → mintRealtimeGrant (server)
2584
- * product: { productId: 'BEEB77', mockupIds: ['front'] },
2585
+ * // mockupIds are opaque catalog SCENE CODES (product.mockups[].id) — NOT
2586
+ * // placement names. variantId is required for products with a color/size axis.
2587
+ * product: { productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC' },
2585
2588
  * });
2586
2589
  * session.onMockups((results) => { img.src = results[0].imageUrl; });
2590
+ * // First arg is the PLACEMENT name (placements[].label, e.g. 'Front') — a
2591
+ * // different id space from mockupIds, and it must match a canvas artboard.
2587
2592
  * await session.renderState('Front', serializeStateForServer(canvasState));
2588
2593
  *
2589
2594
  * For the low-level escape hatch, use {@link RealtimeMockupService} directly.
@@ -2780,6 +2785,15 @@ declare class RenderSession {
2780
2785
  * server/transport errors — with a hint at the most common cause.
2781
2786
  */
2782
2787
  private armWatchdog;
2788
+ /**
2789
+ * Build the watchdog timeout message from the *observed* session state rather
2790
+ * than a single hardcoded guess. The old message always blamed a missing
2791
+ * `variantId` — which sent a clean-room dogfood chasing the wrong cause when
2792
+ * the real problem was elsewhere. Report what's actually true: is the socket
2793
+ * open, did config get acknowledged, and only then fall back to the
2794
+ * variant/catalog hypotheses, naming the exact product so it's checkable.
2795
+ */
2796
+ private timeoutMessage;
2783
2797
  /** Cancel the pending render watchdog, if any. */
2784
2798
  private clearWatchdog;
2785
2799
  /** 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-IMJKV4YO.js";
7
+ } from "./chunk-YTOTCNEW.js";
8
8
 
9
9
  // src/validation.ts
10
10
  import { z } from "zod";
@@ -1643,7 +1643,7 @@ function buildMockupUrl2(options, cfg) {
1643
1643
  }
1644
1644
  function resolveMockupBaseUrl() {
1645
1645
  const winConfig = typeof window !== "undefined" && window.snowcone || {};
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";
1646
+ return winConfig.resolver || typeof process !== "undefined" && process.env?.NEXT_PUBLIC_MOCKUP_RESOLVER_URL || typeof process !== "undefined" && process.env?.SNOWCONE_IMAGE_URL || "https://img.snowcone.app";
1647
1647
  }
1648
1648
  function resolveShop() {
1649
1649
  const winConfig = typeof window !== "undefined" && window.snowcone || {};
@@ -4900,11 +4900,32 @@ var RenderSession = class {
4900
4900
  if (this.renderTimeoutMs <= 0) return;
4901
4901
  this.watchdog = setTimeout(() => {
4902
4902
  this.watchdog = null;
4903
- this.errorCb?.(
4904
- `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)`
4905
- );
4903
+ this.errorCb?.(this.timeoutMessage());
4906
4904
  }, this.renderTimeoutMs);
4907
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
+ }
4908
4929
  /** Cancel the pending render watchdog, if any. */
4909
4930
  clearWatchdog() {
4910
4931
  if (this.watchdog) {
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,21 @@ 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();
54
77
  // Serialized JSON of the last canvas state ACTUALLY SENT per placement. Used to
55
78
  // drop duplicate consecutive states so an idle canvas (whose onChange keeps
56
79
  // firing identical states) doesn't stream renders and burn the realtime budget.
@@ -219,6 +242,16 @@ var RealtimeMockupService = class {
219
242
  });
220
243
  }, 100);
221
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
+ }
222
255
  break;
223
256
  case "blob_received":
224
257
  const placementName = data.placement || "unknown";
@@ -232,8 +265,10 @@ var RealtimeMockupService = class {
232
265
  case "rendering_started":
233
266
  this.addLog("\u{1F3A8} Mockup rendering has started...");
234
267
  break;
235
- case "mockup_rendered":
236
- 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()}`);
237
272
  if (data.imageUrl && data.mockupId) {
238
273
  const responseVersion = data.requestVersion;
239
274
  const responsePlacement = data.placement;
@@ -251,6 +286,7 @@ var RealtimeMockupService = class {
251
286
  imageUrl: data.imageUrl,
252
287
  renderUrl: data.renderUrl || data.imageUrl,
253
288
  imageSize: data.imageSize || 0,
289
+ requestId: data.requestId,
254
290
  requestVersion: responseVersion,
255
291
  placement: responsePlacement,
256
292
  renderMs: data.renderMs,
@@ -273,6 +309,7 @@ var RealtimeMockupService = class {
273
309
  this.addLog(`\u26A0\uFE0F mockup_rendered message dropped: missing required fields [${missing.join(", ")}]. Full data keys: [${Object.keys(data).join(", ")}]`);
274
310
  }
275
311
  break;
312
+ }
276
313
  case "all_mockups_rendered":
277
314
  console.log(`[WS] all_mockups_rendered received: ${data.mockups?.length ?? 0} mockups`);
278
315
  if (data.mockups) {
@@ -309,6 +346,7 @@ var RealtimeMockupService = class {
309
346
  this.connectAttempt++;
310
347
  this.isConnecting = false;
311
348
  this.clearRenew();
349
+ this.pendingStates.clear();
312
350
  if (this.ws) {
313
351
  this.ws.close();
314
352
  this.ws = null;
@@ -480,9 +518,11 @@ var RealtimeMockupService = class {
480
518
  sendCanvasState(placement, state, mockupCount = 1, baseThrottleMs = 1e3) {
481
519
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.isConfigured) {
482
520
  const reason = !this.ws ? "no WebSocket" : this.ws.readyState !== WebSocket.OPEN ? `ws.readyState=${this.ws.readyState}` : "not configured";
483
- console.log(`[WS] sendCanvasState BLOCKED for "${placement}": ${reason}`);
484
- return false;
521
+ this.pendingStates.set(placement, { state, mockupCount, baseThrottleMs });
522
+ console.log(`[WS] sendCanvasState buffered for "${placement}" until configured: ${reason}`);
523
+ return true;
485
524
  }
525
+ this.pendingStates.delete(placement);
486
526
  this.canvasStates.set(placement, state);
487
527
  if (baseThrottleMs <= 0) {
488
528
  this.sendCanvasStateImmediately(placement, state);
@@ -535,18 +575,31 @@ var RealtimeMockupService = class {
535
575
  }
536
576
  this.lastSentVersion = ++this.requestVersion;
537
577
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
578
+ const { requestId, traceparent } = newRenderCorrelation();
538
579
  const message = JSON.stringify({
539
580
  type: "canvas_state",
540
581
  placement,
541
582
  version: this.lastSentVersion,
583
+ requestId,
584
+ traceparent,
542
585
  stateId
543
586
  });
544
587
  this.ws.send(message);
588
+ this.recordRequestSent(requestId);
545
589
  this.lastBlobSentAt = Date.now();
546
- 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");
547
592
  this.callbacks.onBlobSent?.(placement);
548
593
  return true;
549
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
+ }
550
603
  sendCanvasStateImmediately(placement, state) {
551
604
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
552
605
  console.log(`[WS] sendCanvasStateImmediately ABORTED: ws not open`);
@@ -559,16 +612,21 @@ var RealtimeMockupService = class {
559
612
  }
560
613
  this.lastSentVersion = ++this.requestVersion;
561
614
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
615
+ const { requestId, traceparent } = newRenderCorrelation();
562
616
  const message = JSON.stringify({
563
617
  type: "canvas_state",
564
618
  placement,
565
619
  version: this.lastSentVersion,
620
+ requestId,
621
+ traceparent,
566
622
  state
567
623
  });
568
624
  this.ws.send(message);
625
+ this.recordRequestSent(requestId);
569
626
  this.lastSentStateJson[placement] = stateJson;
570
627
  this.lastBlobSentAt = Date.now();
571
- 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");
572
630
  this.callbacks.onBlobSent?.(placement);
573
631
  }
574
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-DTRStLRH.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-DTRStLRH.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-IMJKV4YO.js";
3
+ } from "./chunk-YTOTCNEW.js";
4
4
 
5
5
  // src/realtime/react.ts
6
6
  import { useCallback, useEffect, useRef, useState } from "react";
@@ -230,6 +230,8 @@ interface MockupResult {
230
230
  imageUrl: string;
231
231
  renderUrl: string;
232
232
  imageSize: number;
233
+ /** Per-render correlation id (== OTel trace-id) echoed back by the server */
234
+ requestId?: string;
233
235
  /** The request version this mockup corresponds to (for stale detection) */
234
236
  requestVersion?: number;
235
237
  /** The placement this mockup corresponds to */
@@ -252,6 +254,8 @@ interface WebSocketMessage {
252
254
  message?: string;
253
255
  placement?: string;
254
256
  missingPlacements?: string[];
257
+ /** Per-render correlation id (== OTel trace-id) echoed back by the server */
258
+ requestId?: string;
255
259
  /** The request version this response corresponds to (for stale detection) */
256
260
  requestVersion?: number;
257
261
  /** Server-side PIXI render time in ms */
@@ -305,8 +309,10 @@ declare class RealtimeMockupService {
305
309
  private lastError;
306
310
  private callbacks;
307
311
  private lastBlobSentAt;
312
+ private requestSendTimes;
308
313
  private canvasBlobs;
309
314
  private canvasStates;
315
+ private pendingStates;
310
316
  private lastSentStateJson;
311
317
  private colors;
312
318
  private lastSendTime;
@@ -367,6 +373,8 @@ declare class RealtimeMockupService {
367
373
  * canvas JSON. One-shot (no throttle). Results arrive like any other render.
368
374
  */
369
375
  sendCanvasStateRef(placement: string, stateId: string): boolean;
376
+ /** Stamp a request's send time for client round-trip measurement (capped). */
377
+ private recordRequestSent;
370
378
  private sendCanvasStateImmediately;
371
379
  private sendBlobImmediately;
372
380
  /**
@@ -230,6 +230,8 @@ interface MockupResult {
230
230
  imageUrl: string;
231
231
  renderUrl: string;
232
232
  imageSize: number;
233
+ /** Per-render correlation id (== OTel trace-id) echoed back by the server */
234
+ requestId?: string;
233
235
  /** The request version this mockup corresponds to (for stale detection) */
234
236
  requestVersion?: number;
235
237
  /** The placement this mockup corresponds to */
@@ -252,6 +254,8 @@ interface WebSocketMessage {
252
254
  message?: string;
253
255
  placement?: string;
254
256
  missingPlacements?: string[];
257
+ /** Per-render correlation id (== OTel trace-id) echoed back by the server */
258
+ requestId?: string;
255
259
  /** The request version this response corresponds to (for stale detection) */
256
260
  requestVersion?: number;
257
261
  /** Server-side PIXI render time in ms */
@@ -305,8 +309,10 @@ declare class RealtimeMockupService {
305
309
  private lastError;
306
310
  private callbacks;
307
311
  private lastBlobSentAt;
312
+ private requestSendTimes;
308
313
  private canvasBlobs;
309
314
  private canvasStates;
315
+ private pendingStates;
310
316
  private lastSentStateJson;
311
317
  private colors;
312
318
  private lastSendTime;
@@ -367,6 +373,8 @@ declare class RealtimeMockupService {
367
373
  * canvas JSON. One-shot (no throttle). Results arrive like any other render.
368
374
  */
369
375
  sendCanvasStateRef(placement: string, stateId: string): boolean;
376
+ /** Stamp a request's send time for client round-trip measurement (capped). */
377
+ private recordRequestSent;
370
378
  private sendCanvasStateImmediately;
371
379
  private sendBlobImmediately;
372
380
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowcone-app/sdk",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Snowcone SDK for product mockups and print-on-demand",
5
5
  "keywords": [
6
6
  "merch",
@@ -68,8 +68,14 @@
68
68
  "zod": "^3.23.8"
69
69
  },
70
70
  "devDependencies": {
71
+ "@testing-library/dom": "^10.4.0",
72
+ "@testing-library/react": "^16.3.0",
71
73
  "@types/node": "^20.11.0",
72
74
  "@types/react": "^18.3.0",
75
+ "@types/react-dom": "^18.3.0",
76
+ "happy-dom": "^15.11.7",
77
+ "react": "^18.3.1",
78
+ "react-dom": "^18.3.1",
73
79
  "tsup": "^8.1.0",
74
80
  "typescript": "^5.5.4",
75
81
  "vitest": "^2.0.5",