@snowcone-app/sdk 0.1.12 → 0.1.14

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/README.md CHANGED
@@ -17,50 +17,25 @@ pnpm add @snowcone-app/sdk
17
17
  ## Quick Start
18
18
 
19
19
  ```typescript
20
- import { getProduct, listProducts, generateMockupUrl } from '@snowcone-app/sdk';
20
+ import { getProduct, listProducts, getMockupUrl } from '@snowcone-app/sdk';
21
21
 
22
- // Fetch a product by ID
23
- const product = await getProduct('BEEB77');
24
- console.log(product.name); // "Bella + Canvas 3001 Unisex Jersey Short Sleeve Tee"
25
-
26
- // List all products
22
+ // 1. Find a product (public catalog read — no key needed)
27
23
  const products = await listProducts({ limit: 10 });
28
- console.log(`Found ${products.length} products`);
29
-
30
- // Generate a mockup URL
31
- const mockupUrl = generateMockupUrl({
32
- productId: 'BEEB77',
33
- variantId: 'white-sm',
34
- artworkUrl: 'https://example.com/artwork.png',
35
- placement: 'Front',
36
- alignment: 'center'
37
- });
38
- console.log(mockupUrl); // URL to mockup image
39
- ```
40
-
41
- ## Configuration
42
-
43
- Configure the SDK to point to your Merch endpoint:
44
-
45
- ```typescript
46
- import { config } from '@snowcone-app/sdk';
47
-
48
- config({
49
- endpoint: 'https://api.snowcone.app',
50
- mockupUrl: 'https://i.snowcone.app',
51
- accountId: 'your-account-id',
52
- mode: 'live' // or 'mock' for development
24
+ const product = await getProduct('BEEB77'); // by id or slug
25
+
26
+ // 2. Build a mockup URL. A mockup is a PUBLIC image URL — getMockupUrl is a
27
+ // pure, synchronous builder (no await, no fetch, no secret). The result
28
+ // drops straight into an <img>. Code-first: productCode, then options.
29
+ const url = getMockupUrl('hoodie-black', {
30
+ shop: shop.id, // your Shop ID (publishable, = shop.id)
31
+ asset: 'https://example.com/art.png' // your artwork
53
32
  });
33
+ // <img src={url} />
54
34
  ```
55
35
 
56
- **Environment Variables (Next.js):**
57
- ```bash
58
- NEXT_PUBLIC_MERCH_ENDPOINT=https://api.snowcone.app
59
- NEXT_PUBLIC_MERCH_MOCKUP_URL=https://i.snowcone.app
60
- NEXT_PUBLIC_MERCH_ACCOUNT_ID=your-account-id
61
- ```
62
-
63
- The SDK automatically reads these environment variables if not configured explicitly.
36
+ > The `key` is your **publishable** token — public and safe to expose (like
37
+ > Cloudinary's cloud name). It defaults to your `shop.id`. See the security
38
+ > ladder below if you want to lock things down.
64
39
 
65
40
  ## Core Functions
66
41
 
@@ -97,23 +72,95 @@ const products = await listProducts(options?: {
97
72
  }
98
73
  ```
99
74
 
100
- ### Mockup Generation
75
+ ### Mockup URLs
76
+
77
+ A mockup is a public image URL on `img.snowcone.app`:
78
+
79
+ ```
80
+ https://img.snowcone.app/{productCode}?asset={assetUrl}&key={publishableKey}
81
+ ```
82
+
83
+ You can hand-write it, or use **`getMockupUrl`** — a pure, synchronous,
84
+ isomorphic builder (browser + server, no `await`). It never hand-rolls the query
85
+ string; it delegates to `@snowcone-app/mockup-url`'s `buildPublicMockupUrl`
86
+ (the single source of truth shared byte-for-byte with the edge resolver).
87
+
88
+ ```typescript
89
+ import { getMockupUrl } from '@snowcone-app/sdk';
90
+
91
+ // Code-first form: productCode, then options.
92
+ const url = getMockupUrl(productCode: string, opts: {
93
+ shop: string; // Required: your Shop ID (publishable, = shop.id)
94
+ asset?: string; // Single default-placement image (get-started shorthand)
95
+ design?: Design; // Multi-placement: { [placementKey]: Fill } — takes precedence over `asset`
96
+ options?: Record<string, string>; // Variant picks: { size: "m" } → opt.size=m
97
+ secret?: string; // Optional: per-shop secret → appends an L3 &signature (server only)
98
+ base?: string; // Optional: override the host (default img.snowcone.app)
99
+ width?: number; // Optional: display width
100
+ view?: string; // Optional: camera view / mockup scene (alias: `mockup`)
101
+ variant?: string; // Optional: specific resolved variant (gvid)
102
+ placement?: string; // Optional: specific print area (single-asset only)
103
+ aspect?: '16:9' | '2:3';// Optional: canvas aspect ratio
104
+ }): string;
105
+ ```
106
+
107
+ `Fill` (re-exported from the SDK) is what fills one placement:
101
108
 
102
109
  ```typescript
103
- import { generateMockupUrl } from '@snowcone-app/sdk';
104
-
105
- const url = generateMockupUrl({
106
- productId: string; // Required: Product ID
107
- variantId?: string; // Optional: Specific variant (color/size)
108
- artworkUrl: string; // Required: URL to artwork image
109
- placement?: string; // Optional: Print area (default: 'Front')
110
- alignment?: ImageAlignment; // Optional: How to position artwork
111
- mode?: 'mock' | 'live'; // Optional: Mock or live rendering
110
+ type Fill =
111
+ | string // image URL (shorthand for { src })
112
+ | { src: string; align?: ImageAlignment; tile?: 0.25 | 0.5 | 1 | 2 | 4 }
113
+ | { color: string }; // a color placement (e.g. a cap's Crown)
114
+ type Design = Record<string, Fill>; // placement key fill
115
+ ```
116
+
117
+ #### Multi-placement + variant options
118
+
119
+ ```typescript
120
+ // A front + back tee, size M.
121
+ const url = getMockupUrl('KMYKUK', {
122
+ shop: shop.id,
123
+ options: { size: 'm' },
124
+ design: {
125
+ front: 'https://cdn.example.com/front.png',
126
+ back: { src: 'https://cdn.example.com/back.png', tile: 2, align: 'top' },
127
+ },
128
+ });
129
+ // → …/KMYKUK?shop=…&asset.back=…&tile.back=2&align.back=top&asset.front=…&opt.size=m
130
+
131
+ // A cap with a printed front + chosen Crown/Strap colors (color placements).
132
+ const cap = getMockupUrl('RQNU68', {
133
+ shop: shop.id,
134
+ design: { front: 'https://cdn.example.com/logo.png', crown: { color: '#001f3f' } },
112
135
  });
136
+ // → …/RQNU68?shop=…&color.crown=%23001f3f&asset.front=…
137
+ ```
138
+
139
+ > A **legacy positional form** `getMockupUrl(assetUrl, productCode, opts)` is also
140
+ > accepted (it builds the same URL as `getMockupUrl(productCode, { asset, … })`).
141
+ > Prefer the code-first form above for new code.
142
+
143
+ ### Security ladder
144
+
145
+ The default is open and frictionless; climb only as far as you need:
113
146
 
114
- // ImageAlignment options:
115
- // 'center' | 'top' | 'bottom' | 'left' | 'right' |
116
- // 'far-left' | 'far-right' | 'far-top' | 'far-bottom'
147
+ | Level | Control | Effect |
148
+ |---|---|---|
149
+ | **L0** (default) | none | public token, any domain, any asset |
150
+ | **L1** | domain allowlist | token only renders from your domains |
151
+ | **L2** | asset allowlist | restrict which asset sources composite |
152
+ | **L3** | signed URLs | URL must carry a valid `&s` (HMAC of a per-shop secret) |
153
+
154
+ For **L3**, pass `secret` (server-side only) and `getMockupUrl` appends the
155
+ verified `&signature`:
156
+
157
+ ```typescript
158
+ // On your server/BFF — the secret never ships to the browser.
159
+ const url = getMockupUrl('hoodie-black', {
160
+ shop: shop.id,
161
+ asset: assetUrl,
162
+ secret: process.env.SNOWCONE_SHOP_SECRET,
163
+ });
117
164
  ```
118
165
 
119
166
  ### React Hooks (Optional)
@@ -148,17 +195,6 @@ import type {
148
195
  } from '@snowcone-app/sdk';
149
196
  ```
150
197
 
151
- ## Development Mode
152
-
153
- Use mock mode for development without API calls:
154
-
155
- ```typescript
156
- config({ mode: 'mock' });
157
-
158
- // Returns mock product data
159
- const product = await getProduct('BEEB77');
160
- ```
161
-
162
198
  ## API Reference
163
199
 
164
200
  See full API documentation at: [https://developers.snowcone.app/sdk](https://developers.snowcone.app/sdk)
@@ -13,7 +13,9 @@ var RealtimeMockupService = class {
13
13
  logs = [];
14
14
  lastError = null;
15
15
  callbacks = {};
16
+ lastBlobSentAt = 0;
16
17
  canvasBlobs = /* @__PURE__ */ new Map();
18
+ canvasStates = /* @__PURE__ */ new Map();
17
19
  colors = /* @__PURE__ */ new Map();
18
20
  lastSendTime = {};
19
21
  throttleTimeouts = {};
@@ -23,11 +25,24 @@ var RealtimeMockupService = class {
23
25
  lastSentVersion = 0;
24
26
  // Track latest sent version per placement to detect stale responses
25
27
  latestSentVersionByPlacement = {};
28
+ // Track latest accepted (displayed) version per placement — only drop results
29
+ // older than what we've already shown, not older than what we've sent.
30
+ // This prevents the "version racing" problem during drag where sent versions
31
+ // advance faster than the server can render.
32
+ latestAcceptedVersionByPlacement = {};
26
33
  // Feature flag: server now supports version in blob message
27
34
  sendVersionInBlob = true;
35
+ // Session-grant auth: when set, connect() fetches a short-lived token, opens
36
+ // the WS with `?token=`, and renews ~15s before expiry via a `renew` message.
37
+ tokenProvider;
38
+ renewTimer = null;
28
39
  setCallbacks(callbacks) {
29
40
  this.callbacks = callbacks;
30
41
  }
42
+ /** Provide a grant fetcher to authorize the session (per-shop, renewable). */
43
+ setTokenProvider(fn) {
44
+ this.tokenProvider = fn;
45
+ }
31
46
  getState() {
32
47
  return {
33
48
  isConnected: this.ws?.readyState === WebSocket.OPEN,
@@ -50,9 +65,24 @@ var RealtimeMockupService = class {
50
65
  if (this.ws?.readyState === WebSocket.OPEN) {
51
66
  return;
52
67
  }
68
+ if (this.tokenProvider) {
69
+ this.tokenProvider().then((grant) => {
70
+ this.openSocket(grant.token);
71
+ this.scheduleRenew(grant.expiresAt);
72
+ }).catch((err) => {
73
+ this.addLog(`Failed to obtain realtime grant: ${err}`);
74
+ this.status = "Disconnected";
75
+ });
76
+ return;
77
+ }
78
+ this.openSocket();
79
+ }
80
+ openSocket(token) {
81
+ const url = token ? `${this.wsUrl}${this.wsUrl.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}` : this.wsUrl;
53
82
  this.addLog(`Connecting to ${this.wsUrl}...`);
54
- this.ws = new WebSocket(this.wsUrl);
83
+ this.ws = new WebSocket(url);
55
84
  this.ws.onopen = () => {
85
+ console.log(`[WS] connection OPENED to ${this.wsUrl}`);
56
86
  this.addLog("WebSocket connection opened");
57
87
  this.status = "Connected";
58
88
  };
@@ -66,11 +96,13 @@ var RealtimeMockupService = class {
66
96
  }
67
97
  };
68
98
  this.ws.onclose = (event) => {
99
+ console.log(`[WS] connection CLOSED (code: ${event.code}, reason: "${event.reason}", wasClean: ${event.wasClean})`);
69
100
  this.addLog(`WebSocket connection closed (code: ${event.code})`);
70
101
  this.status = "Disconnected";
71
102
  this.sessionId = null;
72
103
  this.isConfigured = false;
73
104
  this.configSent = false;
105
+ this.clearRenew();
74
106
  this.callbacks.onDisconnected?.();
75
107
  };
76
108
  this.ws.onerror = (error) => {
@@ -78,6 +110,29 @@ var RealtimeMockupService = class {
78
110
  this.status = "Disconnected";
79
111
  };
80
112
  }
113
+ scheduleRenew(expiresAt) {
114
+ this.clearRenew();
115
+ if (!this.tokenProvider) return;
116
+ const ms = Math.max(1e3, expiresAt * 1e3 - Date.now() - 15e3);
117
+ this.renewTimer = setTimeout(() => void this.renew(), ms);
118
+ }
119
+ clearRenew() {
120
+ if (this.renewTimer) {
121
+ clearTimeout(this.renewTimer);
122
+ this.renewTimer = null;
123
+ }
124
+ }
125
+ async renew() {
126
+ if (!this.tokenProvider || this.ws?.readyState !== WebSocket.OPEN) return;
127
+ try {
128
+ const grant = await this.tokenProvider();
129
+ this.ws.send(JSON.stringify({ type: "renew", token: grant.token }));
130
+ this.addLog("\u{1F504} Renewed realtime session token", "sent");
131
+ this.scheduleRenew(grant.expiresAt);
132
+ } catch (err) {
133
+ this.addLog(`Failed to renew realtime grant: ${err}`);
134
+ }
135
+ }
81
136
  handleMessage(data) {
82
137
  switch (data.type) {
83
138
  case "connected":
@@ -129,15 +184,18 @@ var RealtimeMockupService = class {
129
184
  this.addLog("\u{1F3A8} Mockup rendering has started...");
130
185
  break;
131
186
  case "mockup_rendered":
187
+ console.log(`[WS] mockup_rendered received: mockupId=${data.mockupId} hasImageUrl=${!!data.imageUrl} v=${data.requestVersion} placement="${data.placement}" renderMs=${data.renderMs}`);
132
188
  if (data.imageUrl && data.mockupId) {
133
189
  const responseVersion = data.requestVersion;
134
190
  const responsePlacement = data.placement;
135
191
  if (responseVersion !== void 0 && responsePlacement) {
136
- const latestVersion = this.latestSentVersionByPlacement[responsePlacement];
137
- if (latestVersion !== void 0 && responseVersion < latestVersion) {
138
- this.addLog(`\u23ED\uFE0F Ignoring stale mockup v${responseVersion} for "${responsePlacement}" (latest sent: v${latestVersion})`);
192
+ const lastAccepted = this.latestAcceptedVersionByPlacement[responsePlacement];
193
+ if (lastAccepted !== void 0 && responseVersion < lastAccepted) {
194
+ console.log(`[WS] STALE mockup dropped: v${responseVersion} for "${responsePlacement}" (already displayed: v${lastAccepted}, latest sent: v${this.latestSentVersionByPlacement[responsePlacement]})`);
195
+ this.addLog(`\u23ED\uFE0F Ignoring stale mockup v${responseVersion} for "${responsePlacement}" (displayed: v${lastAccepted})`);
139
196
  break;
140
197
  }
198
+ this.latestAcceptedVersionByPlacement[responsePlacement] = responseVersion;
141
199
  }
142
200
  const mockupResult = {
143
201
  mockupId: data.mockupId,
@@ -145,7 +203,10 @@ var RealtimeMockupService = class {
145
203
  renderUrl: data.renderUrl || data.imageUrl,
146
204
  imageSize: data.imageSize || 0,
147
205
  requestVersion: responseVersion,
148
- placement: responsePlacement
206
+ placement: responsePlacement,
207
+ renderMs: data.renderMs,
208
+ blobToRenderMs: data.blobToRenderMs,
209
+ canvasRenderTiming: data.canvasRenderTiming
149
210
  };
150
211
  const existingIndex = this.mockupResults.findIndex((m) => m.mockupId === data.mockupId);
151
212
  if (existingIndex >= 0) {
@@ -164,14 +225,16 @@ var RealtimeMockupService = class {
164
225
  }
165
226
  break;
166
227
  case "all_mockups_rendered":
228
+ console.log(`[WS] all_mockups_rendered received: ${data.mockups?.length ?? 0} mockups`);
167
229
  if (data.mockups) {
168
230
  const freshMockups = data.mockups.filter((mockup) => {
169
231
  if (mockup.requestVersion !== void 0 && mockup.placement) {
170
- const latestVersion = this.latestSentVersionByPlacement[mockup.placement];
171
- if (latestVersion !== void 0 && mockup.requestVersion < latestVersion) {
172
- this.addLog(`\u23ED\uFE0F Filtering stale mockup v${mockup.requestVersion} for "${mockup.placement}" (latest: v${latestVersion})`);
232
+ const lastAccepted = this.latestAcceptedVersionByPlacement[mockup.placement];
233
+ if (lastAccepted !== void 0 && mockup.requestVersion < lastAccepted) {
234
+ this.addLog(`\u23ED\uFE0F Filtering stale mockup v${mockup.requestVersion} for "${mockup.placement}" (displayed: v${lastAccepted})`);
173
235
  return false;
174
236
  }
237
+ this.latestAcceptedVersionByPlacement[mockup.placement] = mockup.requestVersion;
175
238
  }
176
239
  return true;
177
240
  });
@@ -194,6 +257,7 @@ var RealtimeMockupService = class {
194
257
  }
195
258
  }
196
259
  disconnect() {
260
+ this.clearRenew();
197
261
  if (this.ws) {
198
262
  this.ws.close();
199
263
  this.ws = null;
@@ -228,6 +292,7 @@ var RealtimeMockupService = class {
228
292
  this.isConfigured = false;
229
293
  this.mockupResults = [];
230
294
  this.canvasBlobs.clear();
295
+ this.canvasStates.clear();
231
296
  this.colors.clear();
232
297
  this.lastSendTime = {};
233
298
  Object.values(this.throttleTimeouts).forEach((timeout) => clearTimeout(timeout));
@@ -235,6 +300,7 @@ var RealtimeMockupService = class {
235
300
  this.requestVersion = 0;
236
301
  this.lastSentVersion = 0;
237
302
  this.latestSentVersionByPlacement = {};
303
+ this.latestAcceptedVersionByPlacement = {};
238
304
  this.addLog("\u{1F9F9} Cleared all cached canvas/color data for new product");
239
305
  }
240
306
  }
@@ -270,6 +336,30 @@ var RealtimeMockupService = class {
270
336
  this.addLog(`\u{1F3AF} Updating mockupIds to: [${mockupIds.join(", ")}]`);
271
337
  return this.sendConfig(updatedConfig);
272
338
  }
339
+ /**
340
+ * Update render width without changing other config.
341
+ * Used for low-res preview during rapid edits (e.g., 600 while dragging, 1200 on release).
342
+ * Preserves blobs since product/variant don't change.
343
+ */
344
+ updateWidth(width) {
345
+ if (!this.config) {
346
+ this.addLog("Cannot update width: no config set");
347
+ return false;
348
+ }
349
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
350
+ this.addLog("Cannot update width: WebSocket not connected");
351
+ return false;
352
+ }
353
+ if (this.config.width === width) {
354
+ return false;
355
+ }
356
+ const updatedConfig = {
357
+ ...this.config,
358
+ width
359
+ };
360
+ this.addLog(`\u{1F4D0} Updating render width: ${this.config.width} \u2192 ${width}`);
361
+ return this.sendConfig(updatedConfig);
362
+ }
273
363
  /**
274
364
  * Update placementSettings without changing other config.
275
365
  * Used to override scaleMode when canvas editor is active.
@@ -292,6 +382,8 @@ var RealtimeMockupService = class {
292
382
  sendCanvasBlob(placement, blob, mockupCount = 1, baseThrottleMs = 1e3, notifyCallback = true) {
293
383
  this.canvasBlobs.set(placement, blob);
294
384
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.isConfigured) {
385
+ const reason = !this.ws ? "no WebSocket" : this.ws.readyState !== WebSocket.OPEN ? `ws.readyState=${this.ws.readyState}` : "not configured";
386
+ console.log(`[WS] sendCanvasBlob BLOCKED for "${placement}" (${blob.size}B): ${reason} (cached for later)`);
295
387
  return false;
296
388
  }
297
389
  if (baseThrottleMs <= 0) {
@@ -299,16 +391,25 @@ var RealtimeMockupService = class {
299
391
  this.lastSendTime[placement] = Date.now();
300
392
  return true;
301
393
  }
302
- const throttleMs = baseThrottleMs * mockupCount;
303
- const now = Date.now();
304
- const lastSendTime = this.lastSendTime[placement] || 0;
305
- const timeSinceLastSend = now - lastSendTime;
306
- const hasNeverSent = lastSendTime === 0;
307
- if (hasNeverSent || timeSinceLastSend >= throttleMs) {
308
- this.sendBlobImmediately(placement, blob, notifyCallback);
309
- this.lastSendTime[placement] = now;
310
- } else if (!this.throttleTimeouts[placement]) {
311
- const delayTime = throttleMs - timeSinceLastSend;
394
+ const debounceMs = baseThrottleMs * mockupCount;
395
+ const lastSendTime = this.lastSendTime[placement];
396
+ const timeSinceLastSend = lastSendTime ? Date.now() - lastSendTime : 0;
397
+ const isActiveInteraction = lastSendTime && timeSinceLastSend < debounceMs * 3;
398
+ if (isActiveInteraction && timeSinceLastSend >= debounceMs) {
399
+ if (this.throttleTimeouts[placement]) {
400
+ clearTimeout(this.throttleTimeouts[placement]);
401
+ delete this.throttleTimeouts[placement];
402
+ }
403
+ const latestBlob = this.canvasBlobs.get(placement);
404
+ if (latestBlob && this.ws?.readyState === WebSocket.OPEN && this.isConfigured) {
405
+ this.sendBlobImmediately(placement, latestBlob, notifyCallback);
406
+ this.lastSendTime[placement] = Date.now();
407
+ }
408
+ } else {
409
+ if (this.throttleTimeouts[placement]) {
410
+ clearTimeout(this.throttleTimeouts[placement]);
411
+ }
412
+ const delayTime = isActiveInteraction ? debounceMs - timeSinceLastSend : debounceMs;
312
413
  this.throttleTimeouts[placement] = setTimeout(() => {
313
414
  const latestBlob = this.canvasBlobs.get(placement);
314
415
  if (latestBlob && this.ws?.readyState === WebSocket.OPEN && this.isConfigured) {
@@ -320,6 +421,72 @@ var RealtimeMockupService = class {
320
421
  }
321
422
  return true;
322
423
  }
424
+ /**
425
+ * Send canvas state JSON for server-side rendering.
426
+ * Alternative to sendCanvasBlob — the server renders the PNG instead of the client.
427
+ */
428
+ sendCanvasState(placement, state, mockupCount = 1, baseThrottleMs = 1e3) {
429
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.isConfigured) {
430
+ const reason = !this.ws ? "no WebSocket" : this.ws.readyState !== WebSocket.OPEN ? `ws.readyState=${this.ws.readyState}` : "not configured";
431
+ console.log(`[WS] sendCanvasState BLOCKED for "${placement}": ${reason}`);
432
+ return false;
433
+ }
434
+ this.canvasStates.set(placement, state);
435
+ if (baseThrottleMs <= 0) {
436
+ this.sendCanvasStateImmediately(placement, state);
437
+ this.lastSendTime[placement] = Date.now();
438
+ return true;
439
+ }
440
+ const debounceMs = baseThrottleMs * mockupCount;
441
+ const lastSendTime = this.lastSendTime[placement];
442
+ const timeSinceLastSend = lastSendTime ? Date.now() - lastSendTime : 0;
443
+ const isActiveInteraction = lastSendTime && timeSinceLastSend < debounceMs * 3;
444
+ if (isActiveInteraction && timeSinceLastSend >= debounceMs) {
445
+ if (this.throttleTimeouts[placement]) {
446
+ clearTimeout(this.throttleTimeouts[placement]);
447
+ delete this.throttleTimeouts[placement];
448
+ }
449
+ const latestState = this.canvasStates.get(placement);
450
+ if (latestState && this.ws?.readyState === WebSocket.OPEN && this.isConfigured) {
451
+ console.log(`[WS] sendCanvasState "${placement}": max-wait flush (${timeSinceLastSend}ms since last)`);
452
+ this.sendCanvasStateImmediately(placement, latestState);
453
+ this.lastSendTime[placement] = Date.now();
454
+ }
455
+ } else {
456
+ if (this.throttleTimeouts[placement]) {
457
+ clearTimeout(this.throttleTimeouts[placement]);
458
+ }
459
+ const delayTime = isActiveInteraction ? debounceMs - timeSinceLastSend : debounceMs;
460
+ this.throttleTimeouts[placement] = setTimeout(() => {
461
+ const latestState = this.canvasStates.get(placement);
462
+ if (latestState && this.ws?.readyState === WebSocket.OPEN && this.isConfigured) {
463
+ console.log(`[WS] sendCanvasState "${placement}": debounce firing (${debounceMs}ms)`);
464
+ this.sendCanvasStateImmediately(placement, latestState);
465
+ this.lastSendTime[placement] = Date.now();
466
+ }
467
+ delete this.throttleTimeouts[placement];
468
+ }, delayTime);
469
+ }
470
+ return true;
471
+ }
472
+ sendCanvasStateImmediately(placement, state) {
473
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
474
+ console.log(`[WS] sendCanvasStateImmediately ABORTED: ws not open`);
475
+ return;
476
+ }
477
+ this.lastSentVersion = ++this.requestVersion;
478
+ this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
479
+ const message = JSON.stringify({
480
+ type: "canvas_state",
481
+ placement,
482
+ version: this.lastSentVersion,
483
+ state
484
+ });
485
+ this.ws.send(message);
486
+ this.lastBlobSentAt = Date.now();
487
+ this.addLog(`Sent canvas state for "${placement}" (${(message.length / 1024).toFixed(1)}KB, v${this.lastSentVersion})`, "sent");
488
+ this.callbacks.onBlobSent?.(placement);
489
+ }
323
490
  sendBlobImmediately(placement, blob, notifyCallback = true) {
324
491
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
325
492
  this.lastSentVersion = ++this.requestVersion;
@@ -345,6 +512,7 @@ ${versionToSend}
345
512
  combined.set(imageBytes, headerBytes.length);
346
513
  }
347
514
  this.ws.send(combined.buffer);
515
+ this.lastBlobSentAt = Date.now();
348
516
  this.addLog(`Sent canvas blob for placement "${placement}" (${imageBytes.length} bytes, v${versionToSend})`, "sent");
349
517
  if (notifyCallback) {
350
518
  this.callbacks.onBlobSent?.(placement);