@usereq/widget 0.2.24 → 1.0.0-experimental.0

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.
@@ -1,366 +0,0 @@
1
- import {
2
- getCurrentConversation,
3
- listConversationMessages,
4
- mintWidgetSession,
5
- sendPublicChatConfirmation,
6
- sendPublicChatMessage,
7
- startPublicChat,
8
- } from "./api";
9
- import { clearSession, loadSession, saveSession } from "./session-storage";
10
- import type { WidgetMessage, WidgetSessionState } from "../types";
11
- import { sortByCreatedAt } from "./messages";
12
- import {
13
- encodeStopConfirmationPrompt,
14
- encodeStopConfirmationResult,
15
- type StopConfirmationDecision,
16
- } from "../shared/stop-confirmation";
17
- import { widgetDebug, widgetWarn } from "./debug";
18
-
19
- export type WidgetBootstrapState = {
20
- session: WidgetSessionState;
21
- conversationId?: string;
22
- messages: WidgetMessage[];
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
-
48
- export type WidgetBootstrapDeps = {
49
- loadSession: typeof loadSession;
50
- saveSession: typeof saveSession;
51
- mintWidgetSession: typeof mintWidgetSession;
52
- getCurrentConversation: typeof getCurrentConversation;
53
- listConversationMessages: typeof listConversationMessages;
54
- getOrigin: () => string;
55
- now: () => number;
56
- };
57
-
58
- const defaultBootstrapDeps: WidgetBootstrapDeps = {
59
- loadSession,
60
- saveSession,
61
- mintWidgetSession,
62
- getCurrentConversation,
63
- listConversationMessages,
64
- getOrigin: () => window.location.origin,
65
- now: () => Date.now(),
66
- };
67
-
68
- export function isSessionTokenValid(
69
- session: WidgetSessionState | null,
70
- nowMs: number = Date.now()
71
- ): session is WidgetSessionState {
72
- if (!session) return false;
73
- const expiryMs = new Date(session.expiresAt).getTime();
74
- return Number.isFinite(expiryMs) && expiryMs > nowMs;
75
- }
76
-
77
- async function ensureWidgetSession(input: {
78
- agentId: string;
79
- pageUrl?: string;
80
- referrer?: string;
81
- }, deps: WidgetBootstrapDeps = defaultBootstrapDeps, options?: {
82
- refreshWidgetConfig?: boolean;
83
- }): Promise<WidgetSessionState> {
84
- const stored = deps.loadSession(input.agentId);
85
- const hasValidStoredSession = isSessionTokenValid(stored, deps.now());
86
-
87
- if (hasValidStoredSession && !options?.refreshWidgetConfig) {
88
- return stored;
89
- }
90
-
91
- if (hasValidStoredSession && options?.refreshWidgetConfig) {
92
- try {
93
- const minted = await deps.mintWidgetSession({
94
- agentId: input.agentId,
95
- origin: deps.getOrigin(),
96
- pageUrl: input.pageUrl,
97
- referrer: input.referrer,
98
- });
99
-
100
- const refreshed: WidgetSessionState = {
101
- ...stored,
102
- widgetConfig: minted.widgetConfig,
103
- };
104
- deps.saveSession(input.agentId, refreshed);
105
- return refreshed;
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
- );
116
- return stored;
117
- }
118
- }
119
-
120
- const minted = await deps.mintWidgetSession({
121
- agentId: input.agentId,
122
- origin: deps.getOrigin(),
123
- pageUrl: input.pageUrl,
124
- referrer: input.referrer,
125
- });
126
-
127
- const sessionState: WidgetSessionState = {
128
- sessionToken: minted.sessionToken,
129
- expiresAt: minted.expiresAt,
130
- widgetConfig: minted.widgetConfig,
131
- };
132
- deps.saveSession(input.agentId, sessionState);
133
- return sessionState;
134
- }
135
-
136
- export async function bootstrapWidgetWithDeps(input: {
137
- agentId: string;
138
- pageUrl?: string;
139
- referrer?: string;
140
- }, deps: WidgetBootstrapDeps): Promise<WidgetBootstrapState> {
141
- const session = await ensureWidgetSession(input, deps, {
142
- refreshWidgetConfig: true,
143
- });
144
-
145
- let conversationId = session.conversationId;
146
- let messages: WidgetMessage[] = [];
147
-
148
- if (conversationId) {
149
- try {
150
- const transcript = await deps.listConversationMessages({
151
- conversationId,
152
- sessionToken: session.sessionToken,
153
- });
154
- messages = sortByCreatedAt(transcript.data);
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
- );
170
- conversationId = undefined;
171
- messages = [];
172
- }
173
- } else {
174
- try {
175
- const current = await deps.getCurrentConversation(session.sessionToken);
176
- conversationId = current.conversation?.id ?? undefined;
177
- if (conversationId) {
178
- const transcript = await deps.listConversationMessages({
179
- conversationId,
180
- sessionToken: session.sessionToken,
181
- });
182
- messages = sortByCreatedAt(transcript.data);
183
- }
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
- );
195
- conversationId = undefined;
196
- messages = [];
197
- }
198
- }
199
-
200
- const sessionState: WidgetSessionState = {
201
- ...session,
202
- conversationId,
203
- };
204
- deps.saveSession(input.agentId, sessionState);
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
-
215
- return {
216
- session: sessionState,
217
- conversationId,
218
- messages,
219
- };
220
- }
221
-
222
- export async function bootstrapWidget(input: {
223
- agentId: string;
224
- pageUrl?: string;
225
- referrer?: string;
226
- }): Promise<WidgetBootstrapState> {
227
- return bootstrapWidgetWithDeps(input, defaultBootstrapDeps);
228
- }
229
-
230
- /**
231
- * Send a user message. Lazy-starts the conversation on the first send —
232
- * `startPublicChat` is called transparently when no `conversationId` is
233
- * stored yet, so visitors never create empty conversations by opening
234
- * the panel and walking away. When a start happens as part of this call
235
- * the server-inserted welcome message rides back on `welcomeMessage`
236
- * so the caller can reconcile the client-side placeholder bubble with
237
- * the real DB row.
238
- */
239
- export async function submitWidgetMessage(input: {
240
- agentId: string;
241
- content: string;
242
- }): Promise<{
243
- session: WidgetSessionState;
244
- conversationId: string;
245
- messages: WidgetMessage[];
246
- userMessage: WidgetMessage;
247
- assistantMessages: WidgetMessage[];
248
- welcomeMessage?: WidgetMessage;
249
- }> {
250
- let stored = await ensureWidgetSession({
251
- agentId: input.agentId,
252
- pageUrl: window.location.href,
253
- referrer: document.referrer || undefined,
254
- });
255
-
256
- let welcomeMessage: WidgetMessage | undefined;
257
- let conversationId: string;
258
- if (stored.conversationId) {
259
- conversationId = stored.conversationId;
260
- } else {
261
- const started = await startPublicChat({
262
- agentId: input.agentId,
263
- sessionToken: stored.sessionToken,
264
- });
265
- conversationId = started.conversation.id;
266
- stored = { ...stored, conversationId };
267
- saveSession(input.agentId, stored);
268
- welcomeMessage = started.welcomeMessage;
269
- }
270
-
271
- const response = await sendPublicChatMessage({
272
- conversationId,
273
- sessionToken: stored.sessionToken,
274
- content: input.content,
275
- });
276
-
277
- const nextSession: WidgetSessionState = {
278
- ...stored,
279
- conversationId,
280
- };
281
- saveSession(input.agentId, nextSession);
282
-
283
- return {
284
- session: nextSession,
285
- conversationId,
286
- messages: sortByCreatedAt([
287
- ...(welcomeMessage ? [welcomeMessage] : []),
288
- response.userMessage,
289
- ...response.assistantMessages,
290
- ]),
291
- userMessage: response.userMessage,
292
- assistantMessages: response.assistantMessages,
293
- welcomeMessage,
294
- };
295
- }
296
-
297
- export async function submitWidgetStopConfirmation(input: {
298
- agentId: string;
299
- confirmationId: string;
300
- prompt?: string;
301
- }): Promise<{
302
- session: WidgetSessionState;
303
- conversationId: string;
304
- confirmationMessage: WidgetMessage;
305
- }>;
306
- export async function submitWidgetStopConfirmation(input: {
307
- agentId: string;
308
- confirmationId: string;
309
- decision: StopConfirmationDecision;
310
- }): Promise<{
311
- session: WidgetSessionState;
312
- conversationId: string;
313
- confirmationMessage: WidgetMessage;
314
- }>;
315
- export async function submitWidgetStopConfirmation(input: {
316
- agentId: string;
317
- confirmationId: string;
318
- prompt?: string;
319
- decision?: StopConfirmationDecision;
320
- }): Promise<{
321
- session: WidgetSessionState;
322
- conversationId: string;
323
- confirmationMessage: WidgetMessage;
324
- }> {
325
- const stored = await ensureWidgetSession({
326
- agentId: input.agentId,
327
- pageUrl: window.location.href,
328
- referrer: document.referrer || undefined,
329
- });
330
- if (!stored.conversationId) {
331
- throw new Error("Widget conversation not started");
332
- }
333
-
334
- const content =
335
- input.decision != null
336
- ? encodeStopConfirmationResult({
337
- confirmationId: input.confirmationId,
338
- decision: input.decision,
339
- })
340
- : encodeStopConfirmationPrompt({
341
- confirmationId: input.confirmationId,
342
- prompt: input.prompt,
343
- });
344
-
345
- const response = await sendPublicChatConfirmation({
346
- conversationId: stored.conversationId,
347
- sessionToken: stored.sessionToken,
348
- content,
349
- });
350
-
351
- const nextSession: WidgetSessionState = {
352
- ...stored,
353
- conversationId: stored.conversationId,
354
- };
355
- saveSession(input.agentId, nextSession);
356
-
357
- return {
358
- session: nextSession,
359
- conversationId: stored.conversationId,
360
- confirmationMessage: response.confirmationMessage,
361
- };
362
- }
363
-
364
- export async function resetWidgetSession(agentId: string): Promise<void> {
365
- clearSession(agentId);
366
- }
@@ -1,104 +0,0 @@
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,12 +0,0 @@
1
- import type { WidgetMessage } from "../types";
2
-
3
- export function sortByCreatedAt(messages: WidgetMessage[]): WidgetMessage[] {
4
- return [...messages].sort(
5
- (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
6
- );
7
- }
8
-
9
- export function messageRoleLabel(role: WidgetMessage["role"]): string {
10
- return role === "user" ? "You" : "Assistant";
11
- }
12
-
@@ -1,32 +0,0 @@
1
- import type { WidgetSessionState } from "../types";
2
-
3
- function key(agentId: string): string {
4
- return `usereq-widget:${agentId}`;
5
- }
6
-
7
- export function loadSession(agentId: string): WidgetSessionState | null {
8
- try {
9
- const raw = sessionStorage.getItem(key(agentId));
10
- if (!raw) return null;
11
- const parsed = JSON.parse(raw) as WidgetSessionState;
12
- if (
13
- !parsed?.sessionToken ||
14
- !parsed?.expiresAt ||
15
- !parsed?.widgetConfig?.agentId ||
16
- !parsed?.widgetConfig?.name
17
- ) {
18
- return null;
19
- }
20
- return parsed;
21
- } catch {
22
- return null;
23
- }
24
- }
25
-
26
- export function saveSession(agentId: string, value: WidgetSessionState): void {
27
- sessionStorage.setItem(key(agentId), JSON.stringify(value));
28
- }
29
-
30
- export function clearSession(agentId: string): void {
31
- sessionStorage.removeItem(key(agentId));
32
- }
@@ -1,182 +0,0 @@
1
- import { useEffect, useLayoutEffect, useState } from "react";
2
- import type { WidgetTriggerRule } from "../types";
3
-
4
- type Cleanup = () => void;
5
-
6
- function isValidDelay(value: unknown): value is number {
7
- return typeof value === "number" && !Number.isNaN(value) && value >= 0;
8
- }
9
-
10
- function waitForElementById(
11
- id: string,
12
- callback: (element: HTMLElement) => void,
13
- ): Cleanup {
14
- const immediate = document.getElementById(id);
15
- if (immediate instanceof HTMLElement) {
16
- callback(immediate);
17
- return () => undefined;
18
- }
19
-
20
- const observedRoot = document.body ?? document.documentElement;
21
- if (!observedRoot) {
22
- return () => undefined;
23
- }
24
-
25
- let settled = false;
26
- const observer = new MutationObserver(() => {
27
- if (settled) return;
28
- const node = document.getElementById(id);
29
- if (node instanceof HTMLElement) {
30
- settled = true;
31
- observer.disconnect();
32
- callback(node);
33
- }
34
- });
35
-
36
- observer.observe(observedRoot, { childList: true, subtree: true });
37
-
38
- return () => {
39
- settled = true;
40
- observer.disconnect();
41
- };
42
- }
43
-
44
- export function useWidgetTriggerGate(triggerRule?: WidgetTriggerRule | null) {
45
- const [ready, setReady] = useState(triggerRule == null);
46
- const useReadyEffect =
47
- typeof window === "undefined" ? useEffect : useLayoutEffect;
48
-
49
- useReadyEffect(() => {
50
- let canceled = false;
51
-
52
- const markReady = () => {
53
- if (!canceled) {
54
- setReady(true);
55
- }
56
- };
57
-
58
- if (!triggerRule) {
59
- setReady(true);
60
- return () => {
61
- canceled = true;
62
- };
63
- }
64
-
65
- setReady(false);
66
-
67
- let cleanup: Cleanup = () => undefined;
68
-
69
- switch (triggerRule.triggerRuleType) {
70
- case "time_delay": {
71
- if (!isValidDelay(triggerRule.timeDelay)) {
72
- markReady();
73
- return () => {
74
- canceled = true;
75
- };
76
- }
77
-
78
- const timeout = window.setTimeout(
79
- markReady,
80
- triggerRule.timeDelay * 1000,
81
- );
82
- cleanup = () => {
83
- window.clearTimeout(timeout);
84
- };
85
- break;
86
- }
87
-
88
- case "scroll_to_bottom": {
89
- if (!triggerRule.scrollToBottom) {
90
- markReady();
91
- return () => {
92
- canceled = true;
93
- };
94
- }
95
-
96
- const checkBottom = () => {
97
- const scrollTop = window.scrollY;
98
- const viewportHeight = window.innerHeight;
99
- const scrollHeight = document.documentElement.scrollHeight;
100
- if (scrollTop + viewportHeight >= scrollHeight - 1) {
101
- markReady();
102
- }
103
- };
104
-
105
- checkBottom();
106
- window.addEventListener("scroll", checkBottom, { passive: true });
107
- window.addEventListener("resize", checkBottom);
108
- cleanup = () => {
109
- window.removeEventListener("scroll", checkBottom);
110
- window.removeEventListener("resize", checkBottom);
111
- };
112
- break;
113
- }
114
-
115
- case "element_displayed": {
116
- if (!triggerRule.elementDisplayed) {
117
- markReady();
118
- return () => {
119
- canceled = true;
120
- };
121
- }
122
-
123
- let observer: IntersectionObserver | null = null;
124
- const stopWaiting = waitForElementById(
125
- triggerRule.elementDisplayed,
126
- (node) => {
127
- observer = new IntersectionObserver((entries) => {
128
- if (entries.some((entry) => entry.isIntersecting)) {
129
- markReady();
130
- }
131
- });
132
- observer.observe(node);
133
- },
134
- );
135
-
136
- cleanup = () => {
137
- observer?.disconnect();
138
- stopWaiting();
139
- };
140
- break;
141
- }
142
-
143
- case "element_clicked": {
144
- if (!triggerRule.elementClicked) {
145
- markReady();
146
- return () => {
147
- canceled = true;
148
- };
149
- }
150
-
151
- const selector = `#${CSS.escape(triggerRule.elementClicked)}`;
152
- const handler = (event: MouseEvent) => {
153
- const target = event.target;
154
- if (!(target instanceof Element)) return;
155
- if (target.closest(selector)) {
156
- markReady();
157
- }
158
- };
159
-
160
- document.addEventListener("click", handler, true);
161
- cleanup = () => {
162
- document.removeEventListener("click", handler, true);
163
- };
164
- break;
165
- }
166
-
167
- default: {
168
- markReady();
169
- return () => {
170
- canceled = true;
171
- };
172
- }
173
- }
174
-
175
- return () => {
176
- canceled = true;
177
- cleanup();
178
- };
179
- }, [triggerRule]);
180
-
181
- return { ready: ready || triggerRule == null };
182
- }
@@ -1,15 +0,0 @@
1
- type WidgetShadowCssGlobal = typeof globalThis & {
2
- __USEREQ_WIDGET_SHADOW_CSS__?: string;
3
- };
4
-
5
- export const WIDGET_SHADOW_CSS_GLOBAL = "__USEREQ_WIDGET_SHADOW_CSS__";
6
-
7
- export function setWidgetShadowCss(css: string | null | undefined) {
8
- const global = globalThis as WidgetShadowCssGlobal;
9
- global[WIDGET_SHADOW_CSS_GLOBAL] = css?.trim() ? css : undefined;
10
- }
11
-
12
- export function getWidgetShadowCss(): string | null {
13
- const global = globalThis as WidgetShadowCssGlobal;
14
- return global[WIDGET_SHADOW_CSS_GLOBAL]?.trim() || null;
15
- }