@snowcone-app/sdk 0.1.10

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.
@@ -0,0 +1,476 @@
1
+ // src/realtime/websocket.ts
2
+ var RealtimeMockupService = class {
3
+ constructor(wsUrl = "wss://WS_URL_NOT_CONFIGURED.invalid/realtime") {
4
+ this.wsUrl = wsUrl;
5
+ }
6
+ ws = null;
7
+ config = null;
8
+ configSent = false;
9
+ sessionId = null;
10
+ isConfigured = false;
11
+ mockupResults = [];
12
+ status = "Disconnected";
13
+ logs = [];
14
+ lastError = null;
15
+ callbacks = {};
16
+ canvasBlobs = /* @__PURE__ */ new Map();
17
+ colors = /* @__PURE__ */ new Map();
18
+ lastSendTime = {};
19
+ throttleTimeouts = {};
20
+ // Request versioning to detect stale responses
21
+ // Increments each time we send a blob, so we can ignore old server responses
22
+ requestVersion = 0;
23
+ lastSentVersion = 0;
24
+ // Track latest sent version per placement to detect stale responses
25
+ latestSentVersionByPlacement = {};
26
+ // Feature flag: server now supports version in blob message
27
+ sendVersionInBlob = true;
28
+ setCallbacks(callbacks) {
29
+ this.callbacks = callbacks;
30
+ }
31
+ getState() {
32
+ return {
33
+ isConnected: this.ws?.readyState === WebSocket.OPEN,
34
+ sessionId: this.sessionId,
35
+ isConfigured: this.isConfigured,
36
+ mockupResults: [...this.mockupResults],
37
+ status: this.status,
38
+ logs: [...this.logs],
39
+ lastError: this.lastError
40
+ };
41
+ }
42
+ addLog(message, type = "info") {
43
+ const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString();
44
+ const prefix = type === "sent" ? "\u2192 SENT: " : type === "received" ? "\u2190 RECEIVED: " : "\u2022 ";
45
+ const logMessage = `[${timestamp}] ${prefix}${message}`;
46
+ this.logs.push(logMessage);
47
+ this.callbacks.onLog?.(message, type);
48
+ }
49
+ connect() {
50
+ if (this.ws?.readyState === WebSocket.OPEN) {
51
+ return;
52
+ }
53
+ this.addLog(`Connecting to ${this.wsUrl}...`);
54
+ this.ws = new WebSocket(this.wsUrl);
55
+ this.ws.onopen = () => {
56
+ this.addLog("WebSocket connection opened");
57
+ this.status = "Connected";
58
+ };
59
+ this.ws.onmessage = (event) => {
60
+ try {
61
+ const data = JSON.parse(event.data);
62
+ this.addLog(JSON.stringify(data, null, 2), "received");
63
+ this.handleMessage(data);
64
+ } catch (error) {
65
+ this.addLog(`Received non-JSON message: ${event.data}`, "received");
66
+ }
67
+ };
68
+ this.ws.onclose = (event) => {
69
+ this.addLog(`WebSocket connection closed (code: ${event.code})`);
70
+ this.status = "Disconnected";
71
+ this.sessionId = null;
72
+ this.isConfigured = false;
73
+ this.configSent = false;
74
+ this.callbacks.onDisconnected?.();
75
+ };
76
+ this.ws.onerror = (error) => {
77
+ this.addLog(`WebSocket error: ${error}`);
78
+ this.status = "Disconnected";
79
+ };
80
+ }
81
+ handleMessage(data) {
82
+ switch (data.type) {
83
+ case "connected":
84
+ this.sessionId = data.sessionId || null;
85
+ this.status = `Connected (Session: ${data.sessionId || "N/A"})`;
86
+ this.addLog(`\u2705 Session established: ${data.sessionId}`);
87
+ if (data.sessionId) {
88
+ this.callbacks.onConnected?.(data.sessionId);
89
+ }
90
+ if (this.config && !this.configSent) {
91
+ this.addLog("\u{1F4E4} Auto-sending cached config after connection...");
92
+ setTimeout(() => {
93
+ if (this.config && !this.configSent) {
94
+ this.sendConfig(this.config);
95
+ }
96
+ }, 100);
97
+ }
98
+ break;
99
+ case "config_received":
100
+ case "configured":
101
+ this.isConfigured = true;
102
+ this.status = "Configured";
103
+ this.addLog("\u2705 Configuration accepted! You can now send blobs.");
104
+ this.callbacks.onConfigReceived?.();
105
+ const cachedCanvasCount = this.canvasBlobs.size;
106
+ const cachedColorCount = this.colors.size;
107
+ if (cachedCanvasCount > 0 || cachedColorCount > 0) {
108
+ this.addLog(`\u{1F4E6} Auto-sending cached data: ${cachedCanvasCount} canvas blobs, ${cachedColorCount} colors`);
109
+ setTimeout(() => {
110
+ this.canvasBlobs.forEach((blob, placement) => {
111
+ this.sendCanvasBlob(placement, blob, 1, 0, false);
112
+ });
113
+ this.colors.forEach((color, placement) => {
114
+ this.sendColorBlob(placement, color);
115
+ });
116
+ }, 100);
117
+ }
118
+ break;
119
+ case "blob_received":
120
+ const placementName = data.placement || "unknown";
121
+ const missingCount = data.missingPlacements?.length || 0;
122
+ this.addLog(`\u2705 Blob received for "${placementName}" - ${missingCount} placements still needed`);
123
+ this.callbacks.onBlobReceived?.(placementName);
124
+ break;
125
+ case "have_all_blobs":
126
+ this.addLog("\u{1F389} All blobs received! Starting render...");
127
+ break;
128
+ case "rendering_started":
129
+ this.addLog("\u{1F3A8} Mockup rendering has started...");
130
+ break;
131
+ case "mockup_rendered":
132
+ if (data.imageUrl && data.mockupId) {
133
+ const responseVersion = data.requestVersion;
134
+ const responsePlacement = data.placement;
135
+ 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})`);
139
+ break;
140
+ }
141
+ }
142
+ const mockupResult = {
143
+ mockupId: data.mockupId,
144
+ imageUrl: data.imageUrl,
145
+ renderUrl: data.renderUrl || data.imageUrl,
146
+ imageSize: data.imageSize || 0,
147
+ requestVersion: responseVersion,
148
+ placement: responsePlacement
149
+ };
150
+ const existingIndex = this.mockupResults.findIndex((m) => m.mockupId === data.mockupId);
151
+ if (existingIndex >= 0) {
152
+ this.mockupResults[existingIndex] = mockupResult;
153
+ } else {
154
+ this.mockupResults.push(mockupResult);
155
+ }
156
+ this.addLog(`\u2705 Mockup rendered v${responseVersion ?? "?"} for "${responsePlacement ?? "?"}" (${data.imageSize} bytes)`);
157
+ this.callbacks.onMockupRendered?.(mockupResult);
158
+ } else {
159
+ const missing = [
160
+ !data.imageUrl && "imageUrl",
161
+ !data.mockupId && "mockupId"
162
+ ].filter(Boolean);
163
+ this.addLog(`\u26A0\uFE0F mockup_rendered message dropped: missing required fields [${missing.join(", ")}]. Full data keys: [${Object.keys(data).join(", ")}]`);
164
+ }
165
+ break;
166
+ case "all_mockups_rendered":
167
+ if (data.mockups) {
168
+ const freshMockups = data.mockups.filter((mockup) => {
169
+ 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})`);
173
+ return false;
174
+ }
175
+ }
176
+ return true;
177
+ });
178
+ this.mockupResults = freshMockups;
179
+ this.addLog(`\u{1F389} All mockups rendered: ${freshMockups.length} fresh (${data.mockups.length - freshMockups.length} stale filtered)`);
180
+ this.callbacks.onAllMockupsRendered?.(freshMockups);
181
+ } else {
182
+ this.addLog(`\u26A0\uFE0F all_mockups_rendered message dropped: missing 'mockups' array. Full data keys: [${Object.keys(data).join(", ")}]`);
183
+ }
184
+ break;
185
+ case "error":
186
+ const errorMessage = data.message || "Unknown error occurred";
187
+ this.lastError = errorMessage;
188
+ this.addLog(`\u274C Server Error: ${errorMessage}`);
189
+ this.callbacks.onError?.(errorMessage);
190
+ break;
191
+ default:
192
+ this.addLog(`\u{1F914} Unknown message type: ${data.type}`, "received");
193
+ break;
194
+ }
195
+ }
196
+ disconnect() {
197
+ if (this.ws) {
198
+ this.ws.close();
199
+ this.ws = null;
200
+ }
201
+ }
202
+ sendConfig(config) {
203
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
204
+ this.addLog("WebSocket not connected, caching config");
205
+ this.config = config;
206
+ this.configSent = false;
207
+ return false;
208
+ }
209
+ if (!this.sessionId) {
210
+ this.addLog("WebSocket connected but no session yet, caching config");
211
+ this.config = config;
212
+ this.configSent = false;
213
+ return false;
214
+ }
215
+ const hasConfigChanged = !this.config || JSON.stringify(this.config) !== JSON.stringify(config);
216
+ if (this.configSent && !hasConfigChanged) {
217
+ this.addLog("Config already sent and unchanged, skipping duplicate");
218
+ return false;
219
+ }
220
+ if (this.configSent && hasConfigChanged) {
221
+ const isOnlyMockupIdsChange = this.config && this.config.productId === config.productId && this.config.variantId === config.variantId;
222
+ if (isOnlyMockupIdsChange) {
223
+ this.addLog("\u{1F504} MockupIds changed, keeping cached blobs (server will reuse)");
224
+ this.isConfigured = false;
225
+ this.mockupResults = [];
226
+ } else {
227
+ this.addLog("\u{1F504} Product/variant changed, full reset");
228
+ this.isConfigured = false;
229
+ this.mockupResults = [];
230
+ this.canvasBlobs.clear();
231
+ this.colors.clear();
232
+ this.lastSendTime = {};
233
+ Object.values(this.throttleTimeouts).forEach((timeout) => clearTimeout(timeout));
234
+ this.throttleTimeouts = {};
235
+ this.requestVersion = 0;
236
+ this.lastSentVersion = 0;
237
+ this.latestSentVersionByPlacement = {};
238
+ this.addLog("\u{1F9F9} Cleared all cached canvas/color data for new product");
239
+ }
240
+ }
241
+ this.config = config;
242
+ this.configSent = true;
243
+ const message = {
244
+ type: "config",
245
+ config
246
+ };
247
+ const messageStr = JSON.stringify(message);
248
+ this.addLog(`\u{1F4E4} Sending config: ${JSON.stringify(message, null, 2)}`, "sent");
249
+ this.ws.send(messageStr);
250
+ return true;
251
+ }
252
+ /**
253
+ * Update only the mockupIds without changing other config.
254
+ * Server will use already-cached blobs to render the requested mockups.
255
+ * This is the preferred method for priority-based rendering.
256
+ */
257
+ updateMockupIds(mockupIds) {
258
+ if (!this.config) {
259
+ this.addLog("Cannot update mockupIds: no config set");
260
+ return false;
261
+ }
262
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
263
+ this.addLog("Cannot update mockupIds: WebSocket not connected");
264
+ return false;
265
+ }
266
+ const updatedConfig = {
267
+ ...this.config,
268
+ mockupIds
269
+ };
270
+ this.addLog(`\u{1F3AF} Updating mockupIds to: [${mockupIds.join(", ")}]`);
271
+ return this.sendConfig(updatedConfig);
272
+ }
273
+ /**
274
+ * Update placementSettings without changing other config.
275
+ * Used to override scaleMode when canvas editor is active.
276
+ */
277
+ updatePlacementSettings(placementSettings) {
278
+ if (!this.config) {
279
+ this.addLog("Cannot update placementSettings: no config set");
280
+ return false;
281
+ }
282
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
283
+ this.addLog("Cannot update placementSettings: WebSocket not connected");
284
+ return false;
285
+ }
286
+ const updatedConfig = {
287
+ ...this.config,
288
+ placementSettings
289
+ };
290
+ return this.sendConfig(updatedConfig);
291
+ }
292
+ sendCanvasBlob(placement, blob, mockupCount = 1, baseThrottleMs = 1e3, notifyCallback = true) {
293
+ this.canvasBlobs.set(placement, blob);
294
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.isConfigured) {
295
+ return false;
296
+ }
297
+ if (baseThrottleMs <= 0) {
298
+ this.sendBlobImmediately(placement, blob, notifyCallback);
299
+ this.lastSendTime[placement] = Date.now();
300
+ return true;
301
+ }
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;
312
+ this.throttleTimeouts[placement] = setTimeout(() => {
313
+ const latestBlob = this.canvasBlobs.get(placement);
314
+ if (latestBlob && this.ws?.readyState === WebSocket.OPEN && this.isConfigured) {
315
+ this.sendBlobImmediately(placement, latestBlob, notifyCallback);
316
+ this.lastSendTime[placement] = Date.now();
317
+ }
318
+ delete this.throttleTimeouts[placement];
319
+ }, delayTime);
320
+ }
321
+ return true;
322
+ }
323
+ sendBlobImmediately(placement, blob, notifyCallback = true) {
324
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
325
+ this.lastSentVersion = ++this.requestVersion;
326
+ this.latestSentVersionByPlacement[placement] = this.lastSentVersion;
327
+ const versionToSend = this.lastSentVersion;
328
+ const reader = new FileReader();
329
+ reader.onload = (e) => {
330
+ if (e.target?.result && this.ws) {
331
+ const imageBytes = new Uint8Array(e.target.result);
332
+ let combined;
333
+ if (this.sendVersionInBlob) {
334
+ const headerBytes = new TextEncoder().encode(`${placement}
335
+ ${versionToSend}
336
+ `);
337
+ combined = new Uint8Array(headerBytes.length + imageBytes.length);
338
+ combined.set(headerBytes, 0);
339
+ combined.set(imageBytes, headerBytes.length);
340
+ } else {
341
+ const headerBytes = new TextEncoder().encode(`${placement}
342
+ `);
343
+ combined = new Uint8Array(headerBytes.length + imageBytes.length);
344
+ combined.set(headerBytes, 0);
345
+ combined.set(imageBytes, headerBytes.length);
346
+ }
347
+ this.ws.send(combined.buffer);
348
+ this.addLog(`Sent canvas blob for placement "${placement}" (${imageBytes.length} bytes, v${versionToSend})`, "sent");
349
+ if (notifyCallback) {
350
+ this.callbacks.onBlobSent?.(placement);
351
+ }
352
+ }
353
+ };
354
+ reader.readAsArrayBuffer(blob);
355
+ }
356
+ /**
357
+ * Enable sending version number in blob messages.
358
+ * Call this once the server supports the new format: <placement>\n<version>\n<blob>
359
+ */
360
+ enableVersionInBlob(enabled = true) {
361
+ this.sendVersionInBlob = enabled;
362
+ this.addLog(`Version in blob ${enabled ? "enabled" : "disabled"}`);
363
+ }
364
+ /**
365
+ * Flush all pending throttled blobs immediately.
366
+ * Call this when the user finishes an action (e.g., mouse up after drag/flip)
367
+ * to ensure the final state is sent without waiting for throttle.
368
+ */
369
+ flushPendingBlobs() {
370
+ Object.keys(this.throttleTimeouts).forEach((placement) => {
371
+ clearTimeout(this.throttleTimeouts[placement]);
372
+ delete this.throttleTimeouts[placement];
373
+ });
374
+ if (this.ws?.readyState === WebSocket.OPEN && this.isConfigured) {
375
+ this.canvasBlobs.forEach((blob, placement) => {
376
+ this.sendBlobImmediately(placement, blob);
377
+ this.lastSendTime[placement] = Date.now();
378
+ });
379
+ this.addLog(`\u{1F680} Flushed ${this.canvasBlobs.size} pending blob(s)`);
380
+ }
381
+ }
382
+ /**
383
+ * Check if there are pending blobs waiting to be sent (in throttle queue)
384
+ */
385
+ hasPendingBlobs() {
386
+ return Object.keys(this.throttleTimeouts).length > 0;
387
+ }
388
+ sendColorBlob(placement, hexColor) {
389
+ this.colors.set(placement, hexColor);
390
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.isConfigured) {
391
+ return false;
392
+ }
393
+ const message = placement + "\ncolor:" + hexColor;
394
+ const messageBytes = new TextEncoder().encode(message);
395
+ this.ws.send(messageBytes.buffer);
396
+ this.addLog(`Sent color "${hexColor}" for placement "${placement}"`, "sent");
397
+ return true;
398
+ }
399
+ createEmptyCanvasBlob(width = 400, height = 400) {
400
+ return new Promise((resolve, reject) => {
401
+ const canvas = document.createElement("canvas");
402
+ canvas.width = width;
403
+ canvas.height = height;
404
+ const ctx = canvas.getContext("2d");
405
+ if (!ctx) {
406
+ reject(new Error("Could not get canvas context"));
407
+ return;
408
+ }
409
+ ctx.fillStyle = "white";
410
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
411
+ canvas.toBlob(
412
+ (blob) => {
413
+ if (blob) {
414
+ resolve(blob);
415
+ } else {
416
+ reject(new Error("Could not create blob from canvas"));
417
+ }
418
+ },
419
+ "image/png",
420
+ 0.8
421
+ );
422
+ });
423
+ }
424
+ async sendInitialEmptyCanvases(placements) {
425
+ this.addLog(`\u{1F3A8} Creating initial empty canvases for ${placements.length} placements...`);
426
+ let emptyCanvasCount = 0;
427
+ let preservedCanvasCount = 0;
428
+ for (const placement of placements) {
429
+ try {
430
+ const existingBlob = this.canvasBlobs.get(placement.label);
431
+ if (existingBlob) {
432
+ this.addLog(`\u{1F3AF} Preserving existing canvas for "${placement.label}" (${existingBlob.size} bytes)`);
433
+ preservedCanvasCount++;
434
+ if (this.isConfigured) {
435
+ this.sendCanvasBlob(placement.label, existingBlob);
436
+ }
437
+ } else {
438
+ const emptyBlob = await this.createEmptyCanvasBlob(
439
+ placement.width || 400,
440
+ placement.height || 400
441
+ );
442
+ this.canvasBlobs.set(placement.label, emptyBlob);
443
+ this.addLog(`\u2705 Created empty canvas for "${placement.label}" (${emptyBlob.size} bytes)`);
444
+ emptyCanvasCount++;
445
+ if (this.isConfigured) {
446
+ this.sendCanvasBlob(placement.label, emptyBlob);
447
+ }
448
+ }
449
+ } catch (error) {
450
+ this.addLog(`\u274C Failed to process canvas for "${placement.label}": ${error}`);
451
+ }
452
+ }
453
+ this.addLog(`\u{1F389} Canvas processing complete: ${preservedCanvasCount} preserved, ${emptyCanvasCount} created empty`);
454
+ }
455
+ setInitialData(canvasBlobs, colors) {
456
+ const existingCanvasCount = this.canvasBlobs.size;
457
+ if (existingCanvasCount === 0) {
458
+ this.canvasBlobs = new Map(canvasBlobs);
459
+ this.addLog(`Set initial canvas blobs: ${canvasBlobs.size} new blobs`);
460
+ } else {
461
+ this.addLog(`Preserving existing canvas blobs: ${existingCanvasCount} blobs (ignoring ${canvasBlobs.size} new empty blobs)`);
462
+ }
463
+ this.colors = new Map(colors);
464
+ this.addLog(`Set initial colors: ${colors.size} colors`);
465
+ }
466
+ clearLogs() {
467
+ this.logs = [];
468
+ }
469
+ clearMockups() {
470
+ this.mockupResults = [];
471
+ }
472
+ };
473
+
474
+ export {
475
+ RealtimeMockupService
476
+ };