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