@usereq/widget 0.2.17 → 0.2.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usereq/widget",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -343,6 +343,17 @@ export class AgentWidgetElement extends HTMLElement {
343
343
  this.open = true;
344
344
  }
345
345
  this.renderWidget();
346
+ // Bootstrap has finished and status is now "ready". If `autoStart`
347
+ // is on AND the panel is open (either because `defaultOpen` /
348
+ // `alwaysOpen` just opened it, or because the visitor opened it
349
+ // before bootstrap finished and `handleOpenChange` is awaiting
350
+ // this very `ensureBootstrap` call), trigger the auto-start now.
351
+ // Without this, the `defaultOpen + autoStart` combination renders
352
+ // an empty panel with no CTA (autoStart suppresses the Start CTA)
353
+ // and the visitor sees no welcome message ever.
354
+ // `maybeAutoStartConversation` is idempotent — it guards on
355
+ // `autoStartTriggered` so calling it from multiple paths is safe.
356
+ this.maybeAutoStartConversation();
346
357
  } catch (error) {
347
358
  if (runVersion !== this.bootstrapVersion) {
348
359
  return;
@@ -14,6 +14,7 @@ import {
14
14
  encodeStopConfirmationResult,
15
15
  type StopConfirmationDecision,
16
16
  } from "../shared/stop-confirmation";
17
+ import { widgetDebug, widgetWarn } from "./debug";
17
18
 
18
19
  export type WidgetBootstrapState = {
19
20
  session: WidgetSessionState;
@@ -21,6 +22,29 @@ export type WidgetBootstrapState = {
21
22
  messages: WidgetMessage[];
22
23
  };
23
24
 
25
+ /**
26
+ * Compact, JSON-safe error shape for the verbose logs. Strips noisy
27
+ * stack lines but keeps message + name + (if present) `code` so AppError
28
+ * responses surface their server-side code in the customer's console.
29
+ */
30
+ function serializeError(error: unknown): {
31
+ name: string;
32
+ message: string;
33
+ code?: string;
34
+ status?: number;
35
+ } {
36
+ if (error instanceof Error) {
37
+ const err = error as Error & { code?: unknown; status?: unknown };
38
+ return {
39
+ name: err.name,
40
+ message: err.message,
41
+ code: typeof err.code === "string" ? err.code : undefined,
42
+ status: typeof err.status === "number" ? err.status : undefined,
43
+ };
44
+ }
45
+ return { name: "Unknown", message: String(error) };
46
+ }
47
+
24
48
  export type WidgetBootstrapDeps = {
25
49
  loadSession: typeof loadSession;
26
50
  saveSession: typeof saveSession;
@@ -79,7 +103,16 @@ async function ensureWidgetSession(input: {
79
103
  };
80
104
  deps.saveSession(input.agentId, refreshed);
81
105
  return refreshed;
82
- } catch {
106
+ } catch (error) {
107
+ // Network blip during the refresh is recoverable — the cached
108
+ // session is still valid, so we fall back to it. But log so the
109
+ // customer's DevTools shows the underlying API failure (origin
110
+ // not allowed, agent archived, server 5xx, etc.).
111
+ widgetWarn(
112
+ "bootstrap.refreshSession",
113
+ "config refresh failed, using cached session",
114
+ { agentId: input.agentId, error: serializeError(error) },
115
+ );
83
116
  return stored;
84
117
  }
85
118
  }
@@ -119,7 +152,21 @@ export async function bootstrapWidgetWithDeps(input: {
119
152
  sessionToken: session.sessionToken,
120
153
  });
121
154
  messages = sortByCreatedAt(transcript.data);
122
- } catch {
155
+ } catch (error) {
156
+ // Stored conversation id was orphaned (deleted server-side,
157
+ // expired session, etc.). Drop it and act as if we're a fresh
158
+ // visitor. Surface the underlying failure — silent-drop here is
159
+ // exactly what makes "no welcome message + no send" so hard to
160
+ // debug from the outside.
161
+ widgetWarn(
162
+ "bootstrap.listMessages",
163
+ "transcript fetch failed, resetting conversation",
164
+ {
165
+ agentId: input.agentId,
166
+ conversationId,
167
+ error: serializeError(error),
168
+ },
169
+ );
123
170
  conversationId = undefined;
124
171
  messages = [];
125
172
  }
@@ -134,7 +181,17 @@ export async function bootstrapWidgetWithDeps(input: {
134
181
  });
135
182
  messages = sortByCreatedAt(transcript.data);
136
183
  }
137
- } catch {
184
+ } catch (error) {
185
+ // The `current conversation` lookup failed (most commonly a 401
186
+ // because the session token is stale, or a 403 because the host
187
+ // origin isn't in the agent's allow-list). Without surfacing
188
+ // this, the panel renders empty + `sendDisabled=true` and the
189
+ // visitor has no way to recover or signal something's wrong.
190
+ widgetWarn(
191
+ "bootstrap.currentConversation",
192
+ "no conversation could be resolved, starting fresh",
193
+ { agentId: input.agentId, error: serializeError(error) },
194
+ );
138
195
  conversationId = undefined;
139
196
  messages = [];
140
197
  }
@@ -146,6 +203,15 @@ export async function bootstrapWidgetWithDeps(input: {
146
203
  };
147
204
  deps.saveSession(input.agentId, sessionState);
148
205
 
206
+ widgetDebug("bootstrap.ready", "session resolved", {
207
+ agentId: input.agentId,
208
+ hasConversation: Boolean(conversationId),
209
+ messageCount: messages.length,
210
+ autoStart: Boolean(sessionState.widgetConfig.widgetBehavior?.autoStart),
211
+ defaultOpen: Boolean(sessionState.widgetConfig.widgetBehavior?.defaultOpen),
212
+ alwaysOpen: Boolean(sessionState.widgetConfig.widgetBehavior?.alwaysOpen),
213
+ });
214
+
149
215
  return {
150
216
  session: sessionState,
151
217
  conversationId,
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Widget debug logging.
3
+ *
4
+ * Two tiers:
5
+ *
6
+ * 1. ALWAYS-ON warnings (`widgetWarn`) — fired on every silent failure
7
+ * path that would otherwise leave the visitor with a broken widget
8
+ * and no signal in the console. e.g. `/start` returns 403, the
9
+ * transcript fetch dies, the session token is stale. Surfaced via
10
+ * `console.warn` so a customer running our widget on their site can
11
+ * open DevTools and immediately see WHY the chat isn't working —
12
+ * no env vars, no rebuilds, no extra steps.
13
+ *
14
+ * 2. OPT-IN traces (`widgetDebug`) — verbose state-transition logs
15
+ * ("bootstrap started", "session restored", "auto-start fired"). Off
16
+ * by default; turned on by either:
17
+ * - `localStorage.setItem("usereq_debug", "1")` (sticky)
18
+ * - `?usereq_debug=1` in the embed-host's URL (one-shot)
19
+ *
20
+ * Both routes are namespace-prefixed so customer's own console output
21
+ * doesn't get tangled with ours.
22
+ */
23
+
24
+ const PREFIX = "[usereq-widget]";
25
+
26
+ /** True when verbose-trace logging is enabled. Memoized per page load. */
27
+ let debugEnabled: boolean | null = null;
28
+
29
+ export function isWidgetDebugEnabled(): boolean {
30
+ if (debugEnabled !== null) return debugEnabled;
31
+ debugEnabled = computeDebugEnabled();
32
+ return debugEnabled;
33
+ }
34
+
35
+ function computeDebugEnabled(): boolean {
36
+ if (typeof window === "undefined") return false;
37
+ try {
38
+ // Query param is one-shot per page load — handy for "ask the
39
+ // customer to add `?usereq_debug=1` to their URL and reproduce."
40
+ const params = new URLSearchParams(window.location.search);
41
+ if (params.get("usereq_debug") === "1") return true;
42
+ } catch {
43
+ /* iframe with restricted location.search — ignore */
44
+ }
45
+ try {
46
+ if (window.localStorage?.getItem("usereq_debug") === "1") return true;
47
+ } catch {
48
+ /* incognito / disabled localStorage — ignore */
49
+ }
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Always-on warning. Use for silent-failure paths where we currently
55
+ * swallow an error but the visitor's chat will visibly break.
56
+ *
57
+ * Output shape:
58
+ * [usereq-widget] bootstrap.listMessages failed { agentId, sessionToken, error }
59
+ */
60
+ export function widgetWarn(scope: string, message: string, context?: unknown): void {
61
+ if (context === undefined) {
62
+ console.warn(`${PREFIX} ${scope}: ${message}`);
63
+ return;
64
+ }
65
+ console.warn(`${PREFIX} ${scope}: ${message}`, sanitizeForLog(context));
66
+ }
67
+
68
+ /**
69
+ * Opt-in trace. Logs state transitions / network calls when debug mode
70
+ * is on. Production builds keep these as `console.debug` calls; modern
71
+ * DevTools hide `debug` by default so the warning channel stays clean.
72
+ */
73
+ export function widgetDebug(scope: string, message: string, context?: unknown): void {
74
+ if (!isWidgetDebugEnabled()) return;
75
+ if (context === undefined) {
76
+ console.debug(`${PREFIX} ${scope}: ${message}`);
77
+ return;
78
+ }
79
+ console.debug(`${PREFIX} ${scope}: ${message}`, sanitizeForLog(context));
80
+ }
81
+
82
+ /**
83
+ * Strip session tokens / PII from arbitrary context objects before
84
+ * logging. Keeps prefixes long enough to recognize the session at a
85
+ * glance ("eyJh…") without leaking the secret.
86
+ */
87
+ function sanitizeForLog(context: unknown): unknown {
88
+ if (context === null || typeof context !== "object") return context;
89
+ try {
90
+ const json = JSON.parse(
91
+ JSON.stringify(context, (_key, value) => {
92
+ if (typeof value === "string") {
93
+ if (value.length > 32 && /^[A-Za-z0-9_.\-+/=]+$/.test(value)) {
94
+ return `${value.slice(0, 8)}…(redacted, len=${value.length})`;
95
+ }
96
+ }
97
+ return value;
98
+ }),
99
+ );
100
+ return json;
101
+ } catch {
102
+ return context;
103
+ }
104
+ }
@@ -1,6 +1,24 @@
1
1
  export const WIDGET_SHADOW_THEME_CSS = `
2
2
  :host {
3
3
  color-scheme: light;
4
+ /*
5
+ * Anchor inherited typography to the design system.
6
+ *
7
+ * Shadow DOM isolates SELECTORS but NOT inherited properties — the
8
+ * <usereq-agent-widget> custom element inherits color / font-family /
9
+ * font-size / line-height from the host page's <body>, and form
10
+ * controls inside (textarea, input, button) re-inherit via Tailwind's
11
+ * preflight rule \`color: inherit\`. If the host page sets
12
+ * \`body { color: #fff }\` the widget's textarea becomes invisible.
13
+ *
14
+ * Setting these here makes :host the canonical source so every
15
+ * descendant inside the shadow tree uses our tokens regardless of
16
+ * what the host page is doing.
17
+ */
18
+ color: var(--foreground);
19
+ font-family: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
20
+ font-size: 16px;
21
+ line-height: 1.5;
4
22
  --tw-border-style: solid;
5
23
  --radius: 0.625rem;
6
24
  --font-geist-sans: Geist, sans-serif;