@usereq/widget 0.2.18 → 0.2.20
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/dist/widget.js +53 -41
- package/package.json +1 -1
- package/src/custom-element/agent-widget-element.tsx +11 -0
- package/src/runtime/bootstrap.ts +69 -3
- package/src/runtime/debug.ts +104 -0
- package/src/shared/shadow-theme.ts +20 -8
package/package.json
CHANGED
|
@@ -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;
|
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -5,20 +5,32 @@ export const WIDGET_SHADOW_THEME_CSS = `
|
|
|
5
5
|
* Anchor inherited typography to the design system.
|
|
6
6
|
*
|
|
7
7
|
* Shadow DOM isolates SELECTORS but NOT inherited properties — the
|
|
8
|
-
* <usereq-agent-widget> custom element inherits
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* \`body { color: #fff }\` the widget's textarea becomes invisible.
|
|
8
|
+
* <usereq-agent-widget> custom element inherits a long list of text
|
|
9
|
+
* properties from the host page's <body>, and form controls inside
|
|
10
|
+
* (textarea, input, button) re-inherit via Tailwind's preflight rule
|
|
11
|
+
* \`color: inherit\`. Host pages set things like:
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* body { color: #fff; } → textarea text invisible
|
|
14
|
+
* body { text-align: center; } → every message bubble centered
|
|
15
|
+
* body { font-family: cursive; } → widget renders in cursive
|
|
16
|
+
* body { text-transform: uppercase; } → ALL CAPS UI
|
|
17
|
+
*
|
|
18
|
+
* Setting these on :host makes the widget the canonical source so
|
|
19
|
+
* every descendant inside the shadow tree uses our tokens regardless
|
|
20
|
+
* of what the host page is doing. Each line below addresses a
|
|
21
|
+
* commonly-reported customer bleed.
|
|
17
22
|
*/
|
|
18
23
|
color: var(--foreground);
|
|
19
24
|
font-family: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
20
25
|
font-size: 16px;
|
|
26
|
+
font-style: normal;
|
|
27
|
+
font-weight: 400;
|
|
21
28
|
line-height: 1.5;
|
|
29
|
+
text-align: left;
|
|
30
|
+
text-decoration: none;
|
|
31
|
+
text-transform: none;
|
|
32
|
+
letter-spacing: normal;
|
|
33
|
+
word-spacing: normal;
|
|
22
34
|
--tw-border-style: solid;
|
|
23
35
|
--radius: 0.625rem;
|
|
24
36
|
--font-geist-sans: Geist, sans-serif;
|