@snowcone-app/sdk 0.3.3 → 0.3.4

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4
4
+
5
+ ### Patch Changes
6
+
7
+ - [#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.
8
+
9
+ - [#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.
10
+
11
+ - [#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.
12
+
13
+ - [#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.
14
+
3
15
  ## 0.3.3
4
16
 
5
17
  ### Patch Changes
@@ -19,6 +19,10 @@ var RealtimeMockupService = class {
19
19
  lastBlobSentAt = 0;
20
20
  canvasBlobs = /* @__PURE__ */ new Map();
21
21
  canvasStates = /* @__PURE__ */ new Map();
22
+ // Serialized JSON of the last canvas state ACTUALLY SENT per placement. Used to
23
+ // drop duplicate consecutive states so an idle canvas (whose onChange keeps
24
+ // firing identical states) doesn't stream renders and burn the realtime budget.
25
+ lastSentStateJson = {};
22
26
  colors = /* @__PURE__ */ new Map();
23
27
  lastSendTime = {};
24
28
  throttleTimeouts = {};
@@ -39,6 +43,8 @@ var RealtimeMockupService = class {
39
43
  // the WS with `?token=`, and renews ~15s before expiry via a `renew` message.
40
44
  tokenProvider;
41
45
  renewTimer = null;
46
+ isConnecting = false;
47
+ connectAttempt = 0;
42
48
  setCallbacks(callbacks) {
43
49
  this.callbacks = callbacks;
44
50
  }
@@ -65,16 +71,21 @@ var RealtimeMockupService = class {
65
71
  this.callbacks.onLog?.(message, type);
66
72
  }
67
73
  connect() {
68
- if (this.ws?.readyState === WebSocket.OPEN) {
74
+ if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING || this.isConnecting) {
69
75
  return;
70
76
  }
77
+ const attempt = ++this.connectAttempt;
78
+ this.isConnecting = true;
71
79
  if (this.tokenProvider) {
72
80
  this.tokenProvider().then((grant) => {
81
+ if (attempt !== this.connectAttempt || !this.isConnecting) return;
73
82
  this.openSocket(grant.token);
74
83
  this.scheduleRenew(grant.expiresAt);
75
84
  }).catch((err) => {
85
+ if (attempt !== this.connectAttempt) return;
76
86
  this.addLog(`Failed to obtain realtime grant: ${err}`);
77
87
  this.status = "Disconnected";
88
+ this.isConnecting = false;
78
89
  });
79
90
  return;
80
91
  }
@@ -88,6 +99,7 @@ var RealtimeMockupService = class {
88
99
  console.log(`[WS] connection OPENED to ${this.wsUrl}`);
89
100
  this.addLog("WebSocket connection opened");
90
101
  this.status = "Connected";
102
+ this.isConnecting = false;
91
103
  };
92
104
  this.ws.onmessage = (event) => {
93
105
  try {
@@ -106,11 +118,13 @@ var RealtimeMockupService = class {
106
118
  this.isConfigured = false;
107
119
  this.configSent = false;
108
120
  this.clearRenew();
121
+ this.isConnecting = false;
109
122
  this.callbacks.onDisconnected?.();
110
123
  };
111
124
  this.ws.onerror = (error) => {
112
125
  this.addLog(`WebSocket error: ${error}`);
113
126
  this.status = "Disconnected";
127
+ this.isConnecting = false;
114
128
  };
115
129
  }
116
130
  scheduleRenew(expiresAt) {
@@ -260,6 +274,8 @@ var RealtimeMockupService = class {
260
274
  }
261
275
  }
262
276
  disconnect() {
277
+ this.connectAttempt++;
278
+ this.isConnecting = false;
263
279
  this.clearRenew();
264
280
  if (this.ws) {
265
281
  this.ws.close();
@@ -298,6 +314,7 @@ var RealtimeMockupService = class {
298
314
  this.canvasStates.clear();
299
315
  this.colors.clear();
300
316
  this.lastSendTime = {};
317
+ this.lastSentStateJson = {};
301
318
  Object.values(this.throttleTimeouts).forEach((timeout) => clearTimeout(timeout));
302
319
  this.throttleTimeouts = {};
303
320
  this.requestVersion = 0;
@@ -503,6 +520,11 @@ var RealtimeMockupService = class {
503
520
  console.log(`[WS] sendCanvasStateImmediately ABORTED: ws not open`);
504
521
  return;
505
522
  }
523
+ const stateJson = JSON.stringify(state);
524
+ if (this.lastSentStateJson[placement] === stateJson) {
525
+ this.addLog(`Skipped duplicate canvas state for "${placement}" (unchanged)`, "sent");
526
+ return;
527
+ }
506
528
  this.lastSentVersion = ++this.requestVersion;
507
529
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
508
530
  const message = JSON.stringify({
@@ -512,6 +534,7 @@ var RealtimeMockupService = class {
512
534
  state
513
535
  });
514
536
  this.ws.send(message);
537
+ this.lastSentStateJson[placement] = stateJson;
515
538
  this.lastBlobSentAt = Date.now();
516
539
  this.addLog(`Sent canvas state for "${placement}" (${(message.length / 1024).toFixed(1)}KB, v${this.lastSentVersion})`, "sent");
517
540
  this.callbacks.onBlobSent?.(placement);
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();
@@ -4837,6 +4835,10 @@ var RealtimeMockupService = class {
4837
4835
  lastBlobSentAt = 0;
4838
4836
  canvasBlobs = /* @__PURE__ */ new Map();
4839
4837
  canvasStates = /* @__PURE__ */ new Map();
4838
+ // Serialized JSON of the last canvas state ACTUALLY SENT per placement. Used to
4839
+ // drop duplicate consecutive states so an idle canvas (whose onChange keeps
4840
+ // firing identical states) doesn't stream renders and burn the realtime budget.
4841
+ lastSentStateJson = {};
4840
4842
  colors = /* @__PURE__ */ new Map();
4841
4843
  lastSendTime = {};
4842
4844
  throttleTimeouts = {};
@@ -4857,6 +4859,8 @@ var RealtimeMockupService = class {
4857
4859
  // the WS with `?token=`, and renews ~15s before expiry via a `renew` message.
4858
4860
  tokenProvider;
4859
4861
  renewTimer = null;
4862
+ isConnecting = false;
4863
+ connectAttempt = 0;
4860
4864
  setCallbacks(callbacks) {
4861
4865
  this.callbacks = callbacks;
4862
4866
  }
@@ -4883,16 +4887,21 @@ var RealtimeMockupService = class {
4883
4887
  this.callbacks.onLog?.(message, type);
4884
4888
  }
4885
4889
  connect() {
4886
- if (this.ws?.readyState === WebSocket.OPEN) {
4890
+ if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING || this.isConnecting) {
4887
4891
  return;
4888
4892
  }
4893
+ const attempt = ++this.connectAttempt;
4894
+ this.isConnecting = true;
4889
4895
  if (this.tokenProvider) {
4890
4896
  this.tokenProvider().then((grant) => {
4897
+ if (attempt !== this.connectAttempt || !this.isConnecting) return;
4891
4898
  this.openSocket(grant.token);
4892
4899
  this.scheduleRenew(grant.expiresAt);
4893
4900
  }).catch((err) => {
4901
+ if (attempt !== this.connectAttempt) return;
4894
4902
  this.addLog(`Failed to obtain realtime grant: ${err}`);
4895
4903
  this.status = "Disconnected";
4904
+ this.isConnecting = false;
4896
4905
  });
4897
4906
  return;
4898
4907
  }
@@ -4906,6 +4915,7 @@ var RealtimeMockupService = class {
4906
4915
  console.log(`[WS] connection OPENED to ${this.wsUrl}`);
4907
4916
  this.addLog("WebSocket connection opened");
4908
4917
  this.status = "Connected";
4918
+ this.isConnecting = false;
4909
4919
  };
4910
4920
  this.ws.onmessage = (event) => {
4911
4921
  try {
@@ -4924,11 +4934,13 @@ var RealtimeMockupService = class {
4924
4934
  this.isConfigured = false;
4925
4935
  this.configSent = false;
4926
4936
  this.clearRenew();
4937
+ this.isConnecting = false;
4927
4938
  this.callbacks.onDisconnected?.();
4928
4939
  };
4929
4940
  this.ws.onerror = (error) => {
4930
4941
  this.addLog(`WebSocket error: ${error}`);
4931
4942
  this.status = "Disconnected";
4943
+ this.isConnecting = false;
4932
4944
  };
4933
4945
  }
4934
4946
  scheduleRenew(expiresAt) {
@@ -5078,6 +5090,8 @@ var RealtimeMockupService = class {
5078
5090
  }
5079
5091
  }
5080
5092
  disconnect() {
5093
+ this.connectAttempt++;
5094
+ this.isConnecting = false;
5081
5095
  this.clearRenew();
5082
5096
  if (this.ws) {
5083
5097
  this.ws.close();
@@ -5116,6 +5130,7 @@ var RealtimeMockupService = class {
5116
5130
  this.canvasStates.clear();
5117
5131
  this.colors.clear();
5118
5132
  this.lastSendTime = {};
5133
+ this.lastSentStateJson = {};
5119
5134
  Object.values(this.throttleTimeouts).forEach((timeout) => clearTimeout(timeout));
5120
5135
  this.throttleTimeouts = {};
5121
5136
  this.requestVersion = 0;
@@ -5321,6 +5336,11 @@ var RealtimeMockupService = class {
5321
5336
  console.log(`[WS] sendCanvasStateImmediately ABORTED: ws not open`);
5322
5337
  return;
5323
5338
  }
5339
+ const stateJson = JSON.stringify(state);
5340
+ if (this.lastSentStateJson[placement] === stateJson) {
5341
+ this.addLog(`Skipped duplicate canvas state for "${placement}" (unchanged)`, "sent");
5342
+ return;
5343
+ }
5324
5344
  this.lastSentVersion = ++this.requestVersion;
5325
5345
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
5326
5346
  const message = JSON.stringify({
@@ -5330,6 +5350,7 @@ var RealtimeMockupService = class {
5330
5350
  state
5331
5351
  });
5332
5352
  this.ws.send(message);
5353
+ this.lastSentStateJson[placement] = stateJson;
5333
5354
  this.lastBlobSentAt = Date.now();
5334
5355
  this.addLog(`Sent canvas state for "${placement}" (${(message.length / 1024).toFixed(1)}KB, v${this.lastSentVersion})`, "sent");
5335
5356
  this.callbacks.onBlobSent?.(placement);
@@ -5548,6 +5569,20 @@ function assertVariantSelected(product) {
5548
5569
  );
5549
5570
  }
5550
5571
  }
5572
+ function warnUnknownPlacement(product, placement) {
5573
+ const placements = product.placements;
5574
+ if (!placements?.length) return null;
5575
+ if (placements.includes(placement)) return null;
5576
+ 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.`;
5577
+ }
5578
+ function warnPlacementArtboardMismatch(placement, state) {
5579
+ const artboards = state.artboards;
5580
+ if (!Array.isArray(artboards) || artboards.length === 0) return null;
5581
+ const names = artboards.map((a) => a.name).filter((n) => typeof n === "string");
5582
+ if (names.length === 0) return null;
5583
+ if (names.includes(placement)) return null;
5584
+ return `RenderSession: renderState placement "${placement}" has no matching artboard in the state (artboards: [${names.join(", ")}]). The artboard name must equal the placement arg.`;
5585
+ }
5551
5586
  var DEFAULT_RENDER_TIMEOUT_MS = 3e4;
5552
5587
  var RenderSession = class {
5553
5588
  svc;
@@ -5657,6 +5692,7 @@ var RenderSession = class {
5657
5692
  */
5658
5693
  async renderState(placement, state, throttleMs = 0) {
5659
5694
  await this.connect();
5695
+ this.warnPlacement(placement, state);
5660
5696
  this.svc.sendCanvasState(placement, state, this.product?.mockupIds.length ?? 1, throttleMs);
5661
5697
  this.armWatchdog();
5662
5698
  }
@@ -5669,6 +5705,7 @@ var RenderSession = class {
5669
5705
  */
5670
5706
  async renderSavedState(placement, stateId) {
5671
5707
  await this.connect();
5708
+ this.warnPlacement(placement);
5672
5709
  this.svc.sendCanvasStateRef(placement, stateId);
5673
5710
  this.armWatchdog();
5674
5711
  }
@@ -5687,6 +5724,28 @@ var RenderSession = class {
5687
5724
  this.svc.disconnect();
5688
5725
  this.ready = null;
5689
5726
  }
5727
+ /**
5728
+ * Emit loud, non-fatal warnings for the two ways a placement name silently
5729
+ * produces a blank mockup: (1) the name isn't one of the product's known
5730
+ * `placements` labels, and (2) no artboard in `state` is named after it. Each
5731
+ * warning goes to BOTH `console.warn` and the `onError` callback (if wired) so
5732
+ * a dev sees it whichever channel they watch. Never throws.
5733
+ */
5734
+ warnPlacement(placement, state) {
5735
+ const warnings = [];
5736
+ if (this.product) {
5737
+ const w = warnUnknownPlacement(this.product, placement);
5738
+ if (w) warnings.push(w);
5739
+ }
5740
+ if (state) {
5741
+ const w = warnPlacementArtboardMismatch(placement, state);
5742
+ if (w) warnings.push(w);
5743
+ }
5744
+ for (const w of warnings) {
5745
+ console.warn(w);
5746
+ this.errorCb?.(w);
5747
+ }
5748
+ }
5690
5749
  /**
5691
5750
  * (Re)arm the render watchdog. Clears any prior timer and, unless disabled
5692
5751
  * (`renderTimeoutMs <= 0`), starts a fresh one. If it fires before a mockup
@@ -5802,7 +5861,12 @@ async function getProduct(idOrSlug, config) {
5802
5861
  const url = `${meilisearchHost}/indexes/${meilisearchIndex}/documents/${idOrSlug}`;
5803
5862
  const res = await f(url, { method: "GET", headers });
5804
5863
  if (res.status === 404)
5805
- throw Object.assign(new Error("Not found"), { code: "NOT_FOUND" });
5864
+ throw Object.assign(
5865
+ new Error(
5866
+ `getProduct: no catalog product "${idOrSlug}" in index "${meilisearchIndex}". Pass a valid product id/slug (browse with listProducts()).`
5867
+ ),
5868
+ { code: "NOT_FOUND", productId: idOrSlug }
5869
+ );
5806
5870
  if (!res.ok) throw new Error(`getProduct failed: ${res.status}`);
5807
5871
  const raw = await res.json();
5808
5872
  return validateProductLoose(raw);
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-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';
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
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-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';
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
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-IMJKV4YO.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
@@ -4968,7 +5004,12 @@ async function getProduct(idOrSlug, config) {
4968
5004
  const url = `${meilisearchHost}/indexes/${meilisearchIndex}/documents/${idOrSlug}`;
4969
5005
  const res = await f(url, { method: "GET", headers });
4970
5006
  if (res.status === 404)
4971
- throw Object.assign(new Error("Not found"), { code: "NOT_FOUND" });
5007
+ throw Object.assign(
5008
+ new Error(
5009
+ `getProduct: no catalog product "${idOrSlug}" in index "${meilisearchIndex}". Pass a valid product id/slug (browse with listProducts()).`
5010
+ ),
5011
+ { code: "NOT_FOUND", productId: idOrSlug }
5012
+ );
4972
5013
  if (!res.ok) throw new Error(`getProduct failed: ${res.status}`);
4973
5014
  const raw = await res.json();
4974
5015
  return validateProductLoose(raw);
package/dist/react.cjs CHANGED
@@ -51,6 +51,10 @@ var RealtimeMockupService = class {
51
51
  lastBlobSentAt = 0;
52
52
  canvasBlobs = /* @__PURE__ */ new Map();
53
53
  canvasStates = /* @__PURE__ */ new Map();
54
+ // Serialized JSON of the last canvas state ACTUALLY SENT per placement. Used to
55
+ // drop duplicate consecutive states so an idle canvas (whose onChange keeps
56
+ // firing identical states) doesn't stream renders and burn the realtime budget.
57
+ lastSentStateJson = {};
54
58
  colors = /* @__PURE__ */ new Map();
55
59
  lastSendTime = {};
56
60
  throttleTimeouts = {};
@@ -71,6 +75,8 @@ var RealtimeMockupService = class {
71
75
  // the WS with `?token=`, and renews ~15s before expiry via a `renew` message.
72
76
  tokenProvider;
73
77
  renewTimer = null;
78
+ isConnecting = false;
79
+ connectAttempt = 0;
74
80
  setCallbacks(callbacks) {
75
81
  this.callbacks = callbacks;
76
82
  }
@@ -97,16 +103,21 @@ var RealtimeMockupService = class {
97
103
  this.callbacks.onLog?.(message, type);
98
104
  }
99
105
  connect() {
100
- if (this.ws?.readyState === WebSocket.OPEN) {
106
+ if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING || this.isConnecting) {
101
107
  return;
102
108
  }
109
+ const attempt = ++this.connectAttempt;
110
+ this.isConnecting = true;
103
111
  if (this.tokenProvider) {
104
112
  this.tokenProvider().then((grant) => {
113
+ if (attempt !== this.connectAttempt || !this.isConnecting) return;
105
114
  this.openSocket(grant.token);
106
115
  this.scheduleRenew(grant.expiresAt);
107
116
  }).catch((err) => {
117
+ if (attempt !== this.connectAttempt) return;
108
118
  this.addLog(`Failed to obtain realtime grant: ${err}`);
109
119
  this.status = "Disconnected";
120
+ this.isConnecting = false;
110
121
  });
111
122
  return;
112
123
  }
@@ -120,6 +131,7 @@ var RealtimeMockupService = class {
120
131
  console.log(`[WS] connection OPENED to ${this.wsUrl}`);
121
132
  this.addLog("WebSocket connection opened");
122
133
  this.status = "Connected";
134
+ this.isConnecting = false;
123
135
  };
124
136
  this.ws.onmessage = (event) => {
125
137
  try {
@@ -138,11 +150,13 @@ var RealtimeMockupService = class {
138
150
  this.isConfigured = false;
139
151
  this.configSent = false;
140
152
  this.clearRenew();
153
+ this.isConnecting = false;
141
154
  this.callbacks.onDisconnected?.();
142
155
  };
143
156
  this.ws.onerror = (error) => {
144
157
  this.addLog(`WebSocket error: ${error}`);
145
158
  this.status = "Disconnected";
159
+ this.isConnecting = false;
146
160
  };
147
161
  }
148
162
  scheduleRenew(expiresAt) {
@@ -292,6 +306,8 @@ var RealtimeMockupService = class {
292
306
  }
293
307
  }
294
308
  disconnect() {
309
+ this.connectAttempt++;
310
+ this.isConnecting = false;
295
311
  this.clearRenew();
296
312
  if (this.ws) {
297
313
  this.ws.close();
@@ -330,6 +346,7 @@ var RealtimeMockupService = class {
330
346
  this.canvasStates.clear();
331
347
  this.colors.clear();
332
348
  this.lastSendTime = {};
349
+ this.lastSentStateJson = {};
333
350
  Object.values(this.throttleTimeouts).forEach((timeout) => clearTimeout(timeout));
334
351
  this.throttleTimeouts = {};
335
352
  this.requestVersion = 0;
@@ -535,6 +552,11 @@ var RealtimeMockupService = class {
535
552
  console.log(`[WS] sendCanvasStateImmediately ABORTED: ws not open`);
536
553
  return;
537
554
  }
555
+ const stateJson = JSON.stringify(state);
556
+ if (this.lastSentStateJson[placement] === stateJson) {
557
+ this.addLog(`Skipped duplicate canvas state for "${placement}" (unchanged)`, "sent");
558
+ return;
559
+ }
538
560
  this.lastSentVersion = ++this.requestVersion;
539
561
  this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
540
562
  const message = JSON.stringify({
@@ -544,6 +566,7 @@ var RealtimeMockupService = class {
544
566
  state
545
567
  });
546
568
  this.ws.send(message);
569
+ this.lastSentStateJson[placement] = stateJson;
547
570
  this.lastBlobSentAt = Date.now();
548
571
  this.addLog(`Sent canvas state for "${placement}" (${(message.length / 1024).toFixed(1)}KB, v${this.lastSentVersion})`, "sent");
549
572
  this.callbacks.onBlobSent?.(placement);
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-DTRStLRH.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-DTRStLRH.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-IMJKV4YO.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
  /**
@@ -306,6 +307,7 @@ declare class RealtimeMockupService {
306
307
  private lastBlobSentAt;
307
308
  private canvasBlobs;
308
309
  private canvasStates;
310
+ private lastSentStateJson;
309
311
  private colors;
310
312
  private lastSendTime;
311
313
  private throttleTimeouts;
@@ -316,6 +318,8 @@ declare class RealtimeMockupService {
316
318
  private sendVersionInBlob;
317
319
  private tokenProvider?;
318
320
  private renewTimer;
321
+ private isConnecting;
322
+ private connectAttempt;
319
323
  constructor(wsUrl?: string);
320
324
  setCallbacks(callbacks: RealtimeMockupCallbacks): void;
321
325
  /** Provide a grant fetcher to authorize the session (per-shop, renewable). */
@@ -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
  /**
@@ -306,6 +307,7 @@ declare class RealtimeMockupService {
306
307
  private lastBlobSentAt;
307
308
  private canvasBlobs;
308
309
  private canvasStates;
310
+ private lastSentStateJson;
309
311
  private colors;
310
312
  private lastSendTime;
311
313
  private throttleTimeouts;
@@ -316,6 +318,8 @@ declare class RealtimeMockupService {
316
318
  private sendVersionInBlob;
317
319
  private tokenProvider?;
318
320
  private renewTimer;
321
+ private isConnecting;
322
+ private connectAttempt;
319
323
  constructor(wsUrl?: string);
320
324
  setCallbacks(callbacks: RealtimeMockupCallbacks): void;
321
325
  /** Provide a grant fetcher to authorize the session (per-shop, renewable). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowcone-app/sdk",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Snowcone SDK for product mockups and print-on-demand",
5
5
  "keywords": [
6
6
  "merch",
@@ -81,6 +81,7 @@
81
81
  "scripts": {
82
82
  "build": "tsup src/index.ts src/react.ts src/dev-fetcher.ts --format esm,cjs --dts --external zod --external react",
83
83
  "clean": "rm -rf dist",
84
- "typecheck": "tsc --project ./tsconfig.json --noEmit"
84
+ "typecheck": "tsc --project ./tsconfig.json --noEmit",
85
+ "test": "vitest run"
85
86
  }
86
87
  }