keystone-design-bootstrap 1.0.104 → 1.0.105-dev.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.104",
3
+ "version": "1.0.105-dev.0",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -7,6 +7,7 @@ import { cx } from '../../utils/cx';
7
7
  import { captureEvent } from '../../tracking/captureEvent';
8
8
  import { useRealtimeReplyOrchestrator, type RealtimeSubscriptionData } from '../chat/useRealtimeReplyOrchestrator';
9
9
  import { error as logError } from '../../tracking/logging';
10
+ import { RAILS_BACKEND, SOR_BACKEND, type ChatBackend } from '../../lib/chat-backend';
10
11
 
11
12
  interface Message {
12
13
  id: string;
@@ -25,6 +26,18 @@ interface TeamMember {
25
26
 
26
27
  interface ChatWidgetProps {
27
28
  position?: 'bottom-right' | 'bottom-left';
29
+ /**
30
+ * Which backend the site's `/api/chat` proxy talks to. The two backends have
31
+ * architecturally different reply delivery: 'rails' (keystone-mono-proto)
32
+ * pushes replies over ActionCable realtime; 'sor' (sor-service) has no
33
+ * realtime channel and the widget polls for the reply instead. The paths are
34
+ * mutually exclusive — a 'sor' widget never probes/subscribes realtime, a
35
+ * 'rails' widget never polls.
36
+ *
37
+ * TODO(webchat): default is 'rails' while keystone-mono-proto is still a live
38
+ * backend; flip the default to 'sor' once the Rails backend is scrapped.
39
+ */
40
+ chatBackend?: ChatBackend;
28
41
  primaryColor?: string;
29
42
  /**
30
43
  * Anonymous session identifier. Used when the visitor is not authenticated.
@@ -66,6 +79,7 @@ const formatTime = (isoString: string) => {
66
79
  export function ChatWidget({
67
80
  position = 'bottom-right',
68
81
  primaryColor,
82
+ chatBackend = RAILS_BACKEND,
69
83
  sessionId: providedSessionId,
70
84
  contactId,
71
85
  displayName: providedDisplayName,
@@ -146,7 +160,10 @@ export function ChatWidget({
146
160
  );
147
161
  }, []);
148
162
 
163
+ const isSorBackend = chatBackend === SOR_BACKEND;
164
+
149
165
  const fetchRealtimeData = useCallback(async (): Promise<RealtimeSubscriptionData | null> => {
166
+ if (isSorBackend) return null; // sor has no realtime channel — never probe
150
167
  if (!contactId && !sessionId) return null;
151
168
  const query = contactId
152
169
  ? `contact_id=${encodeURIComponent(contactId)}`
@@ -159,7 +176,7 @@ export function ChatWidget({
159
176
  const cableUrl = result?.data?.cable_url as string | undefined;
160
177
  if (!token || !contactIdForStream || !cableUrl) return null;
161
178
  return { token, contact_id: contactIdForStream, cable_url: cableUrl };
162
- }, [contactId, sessionId]);
179
+ }, [contactId, isSorBackend, sessionId]);
163
180
 
164
181
  const {
165
182
  beginReplyWait,
@@ -179,13 +196,48 @@ export function ChatWidget({
179
196
 
180
197
  const hasPersistedMessages = messages.some((message) => !String(message.id).startsWith('temp_'));
181
198
 
199
+ // rails reply delivery: ActionCable realtime subscription.
182
200
  useEffect(() => {
201
+ if (isSorBackend) return;
183
202
  if (!isOpen || (!contactId && !sessionId)) return;
184
203
  // Anonymous sessions do not have a contact row until the first message is sent.
185
204
  // Ignore optimistic temp rows to avoid pre-contact token probes (404 noise).
186
205
  if (!contactId && !hasPersistedMessages) return;
187
206
  ensureRealtimeSubscription();
188
- }, [contactId, ensureRealtimeSubscription, hasPersistedMessages, isOpen, sessionId]);
207
+ }, [contactId, ensureRealtimeSubscription, hasPersistedMessages, isOpen, isSorBackend, sessionId]);
208
+
209
+ // sor reply delivery: no realtime channel exists, so while waiting for the
210
+ // agent reply, poll the thread and resolve as soon as an agent message with
211
+ // a body lands (or after a timeout). Never runs for the rails backend.
212
+ useEffect(() => {
213
+ if (!isSorBackend || !waitingForReply) return;
214
+ let cancelled = false;
215
+ let inFlight = false; // don't stack requests when a poll outlives the interval
216
+ let attempts = 0;
217
+ const maxAttempts = 40; // ~60s at 1.5s
218
+ const interval = setInterval(async () => {
219
+ if (inFlight) return;
220
+ inFlight = true;
221
+ attempts += 1;
222
+ try {
223
+ const msgs = await loadMessages();
224
+ if (cancelled) return;
225
+ if (hasAgentReplyWithBody(msgs) || attempts >= maxAttempts) {
226
+ clearInterval(interval);
227
+ setWaitingForReply(false);
228
+ setIsLoading(false);
229
+ }
230
+ } catch {
231
+ // transient — keep polling until maxAttempts
232
+ } finally {
233
+ inFlight = false;
234
+ }
235
+ }, 1500);
236
+ return () => {
237
+ cancelled = true;
238
+ clearInterval(interval);
239
+ };
240
+ }, [isSorBackend, waitingForReply, loadMessages, hasAgentReplyWithBody]);
189
241
 
190
242
  const sendMessage = async () => {
191
243
  if (!inputValue.trim() || (!contactId && !sessionId)) return;
@@ -220,15 +272,19 @@ export function ChatWidget({
220
272
  captureEvent('chat_message_sent', { is_authenticated: Boolean(contactId) });
221
273
 
222
274
  if (result.data?.job_id) {
275
+ // setWaitingForReply arms the sor polling effect; rails additionally
276
+ // subscribes to the realtime channel for push delivery.
223
277
  setWaitingForReply(true);
224
- const realtimeFromSend = result.data?.realtime_token && result.data?.contact_id && result.data?.cable_url
225
- ? {
226
- token: result.data.realtime_token,
227
- contact_id: result.data.contact_id,
228
- cable_url: result.data.cable_url,
229
- }
230
- : null;
231
- beginReplyWait({ realtimeData: realtimeFromSend });
278
+ if (!isSorBackend) {
279
+ const realtimeFromSend = result.data?.realtime_token && result.data?.contact_id && result.data?.cable_url
280
+ ? {
281
+ token: result.data.realtime_token,
282
+ contact_id: result.data.contact_id,
283
+ cable_url: result.data.cable_url,
284
+ }
285
+ : null;
286
+ beginReplyWait({ realtimeData: realtimeFromSend });
287
+ }
232
288
  } else if (result.data?.status === 'agent_unavailable' || result.data?.status === 'no_auto_reply') {
233
289
  setIsLoading(false);
234
290
  setWaitingForReply(false);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Which chat backend the site's /api/chat proxy targets.
3
+ *
4
+ * The two backends have architecturally different webchat contracts:
5
+ * - 'rails' (keystone-mono-proto): /public/messages + ActionCable realtime.
6
+ * - 'sor' (sor-service): /public/chat/* + widget long-polling.
7
+ *
8
+ * Resolution order:
9
+ * 1. CHAT_BACKEND env ('rails' | 'sor') — explicit override; needed for
10
+ * custom domains or local dev, where the API_URL hostname carries no signal.
11
+ * 2. Derived from API_URL: a hostname containing a `sor` label
12
+ * (sor.localkeystone.com, sor.dev.localkeystone.com, …) ⇒ 'sor'.
13
+ * 3. Default 'rails'.
14
+ *
15
+ * TODO(webchat): default is 'rails' while keystone-mono-proto is still a live
16
+ * backend; flip the default to 'sor' once the Rails backend is scrapped.
17
+ */
18
+ import { serverError } from './server-log';
19
+
20
+ export const RAILS_BACKEND = 'rails' as const;
21
+ export const SOR_BACKEND = 'sor' as const;
22
+ export type ChatBackend = typeof RAILS_BACKEND | typeof SOR_BACKEND;
23
+
24
+ export function resolveChatBackend(): ChatBackend {
25
+ const explicit = process.env.CHAT_BACKEND;
26
+ if (explicit === SOR_BACKEND || explicit === RAILS_BACKEND) return explicit;
27
+
28
+ const apiUrl = process.env.API_URL || '';
29
+ try {
30
+ const host = new URL(apiUrl).hostname;
31
+ if (host.split('.').includes(SOR_BACKEND)) return SOR_BACKEND;
32
+ } catch (error) {
33
+ serverError('CHAT_BACKEND_API_URL_PARSE_ERROR', error, { apiUrl });
34
+ }
35
+ return RAILS_BACKEND;
36
+ }
@@ -11,6 +11,7 @@ import {
11
11
  KeystoneAnalyticsTracker,
12
12
  } from '../../tracking';
13
13
  import { ChatWidget } from '../../design_system/components/ChatWidget';
14
+ import { resolveChatBackend } from '../../lib/chat-backend';
14
15
  import { FormDefinitionsProvider } from '../contexts/form-definitions';
15
16
  import { KeystoneSSRProvider } from '../providers/ssr-provider';
16
17
  import {
@@ -182,6 +183,9 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
182
183
  const portalUrl = ci?.portal_url?.trim() || null;
183
184
  const bookingHref = portalUrl ?? externalManagementUrl ?? null;
184
185
  const chatEnabled = Boolean(ci?.chat_enabled);
186
+ // Which backend the site's /api/chat proxy targets — same resolution as the
187
+ // route handlers (CHAT_BACKEND override, else derived from API_URL).
188
+ const chatBackend = resolveChatBackend();
185
189
 
186
190
  const headerOverrides = options?.headerOverrides;
187
191
  const posthogHost = options?.posthogHost?.trim() || undefined;
@@ -229,6 +233,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
229
233
  {chatEnabled ? (
230
234
  <ChatWidget
231
235
  position={options?.chatPosition || 'bottom-right'}
236
+ chatBackend={chatBackend}
232
237
  teamMembers={teamMembers}
233
238
  />
234
239
  ) : null}
@@ -5,13 +5,27 @@
5
5
  * // app/api/chat/route.ts
6
6
  * export { GET, POST } from 'keystone-design-bootstrap/next/routes/chat';
7
7
  *
8
- * Supports both anonymous (session-based) and authenticated (contact-driven) flows.
9
- * Both flows hit the same backend endpoint (`/public/messages`); the discriminator
10
- * is whether `identifier` (session string) or `contact_id` (integer) is supplied.
8
+ * Supports TWO backends, selected per site (default: 'rails', so existing
9
+ * consumers are unaffected):
10
+ *
11
+ * createChatRouteHandlers({ backend: 'sor' }) // explicit option
12
+ * CHAT_BACKEND=sor // env override
13
+ * (otherwise derived from API_URL — a `sor` hostname label ⇒ 'sor';
14
+ * see lib/chat-backend.ts)
15
+ *
16
+ * - 'rails' (keystone-mono-proto): anonymous (`identifier`) and authenticated
17
+ * (`contact_id`) flows against `/public/messages`, plus the
18
+ * `action=realtime_token` + `cable_url` flow that feeds ActionCable.
19
+ * - 'sor' (sor-service): anonymous webchat against `/public/chat/*` —
20
+ * `session_id` (first-party cookie) + `{ messages, cursor }`, translated to
21
+ * the widget's existing wire shape. No realtime; the widget's polling
22
+ * fallback covers reply delivery. The `realtime_token` action returns an
23
+ * empty payload (no `cable_url`) so the widget knows realtime is unavailable.
11
24
  *
12
25
  * Env (server-side only):
13
- * - API_URL (default: http://localhost:3000/api/v1)
14
- * - API_KEY
26
+ * - API_URL (default: http://localhost:3000/api/v1) — backend base, includes /api/v1
27
+ * - API_KEY — business public key (X-API-Key)
28
+ * - CHAT_BACKEND ('rails' | 'sor', default 'rails')
15
29
  */
16
30
 
17
31
  // IMPORTANT:
@@ -23,28 +37,31 @@
23
37
 
24
38
  import { clientContextHeaders } from './proxy-headers';
25
39
  import { serverError } from '../../lib/server-log';
40
+ import { resolveChatBackend, SOR_BACKEND, type ChatBackend } from '../../lib/chat-backend';
26
41
 
27
42
  const API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';
28
43
  const API_KEY = process.env.API_KEY || '';
29
44
  const CONSUMER_TOKEN_COOKIE = 'ks_consumer_token';
45
+ const SESSION_COOKIE = 'ks_chat_session';
46
+
47
+ export type { ChatBackend };
30
48
 
31
49
  type JsonResponder = (body: unknown, init?: ResponseInit) => Response;
32
50
 
33
- function cookieValue(cookieHeader: string | null, key: string): string | null {
34
- if (!cookieHeader) return null;
35
- const token = cookieHeader
36
- .split(';')
37
- .map((part) => part.trim())
38
- .find((part) => part.startsWith(`${key}=`))
39
- ?.split('=')
40
- .slice(1)
41
- .join('=');
42
- return token ? decodeURIComponent(token) : null;
51
+ function readCookie(request: Request, name: string): string | null {
52
+ const header = request.headers.get('cookie') || '';
53
+ for (const part of header.split(';')) {
54
+ const [k, ...v] = part.trim().split('=');
55
+ if (k === name) return decodeURIComponent(v.join('='));
56
+ }
57
+ return null;
43
58
  }
44
59
 
45
- export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResponder } }) {
46
- const json: JsonResponder = deps?.NextResponse?.json ?? ((body, init) => Response.json(body, init));
60
+ /* ------------------------------------------------------------------------ *
61
+ * rails backend (keystone-mono-proto)
62
+ * ------------------------------------------------------------------------ */
47
63
 
64
+ function createRailsHandlers(json: JsonResponder) {
48
65
  return {
49
66
  // POST /api/chat — send a message (session-based or contact-driven)
50
67
  POST: async (request: Request): Promise<Response> => {
@@ -103,7 +120,7 @@ export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResp
103
120
 
104
121
  const response = contactId
105
122
  ? await (async () => {
106
- const consumerToken = cookieValue(request.headers.get('cookie'), CONSUMER_TOKEN_COOKIE);
123
+ const consumerToken = readCookie(request, CONSUMER_TOKEN_COOKIE);
107
124
  if (!consumerToken) {
108
125
  return new Response(JSON.stringify({ error: 'Unauthorized' }), {
109
126
  status: 401,
@@ -163,3 +180,183 @@ export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResp
163
180
  },
164
181
  };
165
182
  }
183
+
184
+ /* ------------------------------------------------------------------------ *
185
+ * sor backend (sor-service)
186
+ * ------------------------------------------------------------------------ */
187
+
188
+ /**
189
+ * Real browser signals to forward upstream. sor-service reads the visitor IP
190
+ * from X-Forwarded-For, the user-agent from User-Agent, and fbp/fbc from the
191
+ * POST body — so we set the headers and return fbp/fbc for the body.
192
+ */
193
+ function clientContext(request: Request): {
194
+ headers: Record<string, string>;
195
+ fbp?: string;
196
+ fbc?: string;
197
+ } {
198
+ const ip =
199
+ request.headers.get('x-real-ip') ||
200
+ request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
201
+ const ua = request.headers.get('user-agent') || undefined;
202
+ const headers: Record<string, string> = {};
203
+ if (ip) headers['X-Forwarded-For'] = ip;
204
+ if (ua) headers['User-Agent'] = ua;
205
+ return {
206
+ headers,
207
+ fbp: readCookie(request, '_fbp') || undefined,
208
+ fbc: readCookie(request, '_fbc') || undefined,
209
+ };
210
+ }
211
+
212
+ function sessionCookieHeader(sessionId: string): string {
213
+ // First-party (set by the site's own origin via this proxy), so it survives
214
+ // third-party-cookie blocking. 1-year, lax, http-only.
215
+ return `${SESSION_COOKIE}=${encodeURIComponent(sessionId)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`;
216
+ }
217
+
218
+ interface SorMessage {
219
+ id: string;
220
+ direction?: string;
221
+ sender_type?: string;
222
+ body?: string | null;
223
+ created_at?: string | null;
224
+ }
225
+
226
+ /** Translate a sor-service message to the widget's flat shape. */
227
+ function toWidgetMessage(m: SorMessage) {
228
+ const isAgent = m.direction === 'OUTBOUND' || (!!m.sender_type && m.sender_type !== 'CONSUMER');
229
+ return {
230
+ id: m.id,
231
+ body: m.body ?? '',
232
+ sender_type: isAgent ? 'agent' : 'contact',
233
+ sender_display_name: isAgent ? 'Assistant' : 'You',
234
+ created_at: m.created_at ?? new Date().toISOString(),
235
+ };
236
+ }
237
+
238
+ function createSorHandlers(json: JsonResponder) {
239
+ return {
240
+ // POST /api/chat — send a message (anonymous session flow)
241
+ POST: async (request: Request): Promise<Response> => {
242
+ try {
243
+ const body = await request.json();
244
+ const {
245
+ identifier,
246
+ session_id,
247
+ body: messageBody,
248
+ display_name,
249
+ email,
250
+ phone,
251
+ first_name,
252
+ last_name,
253
+ page_url,
254
+ } = body;
255
+
256
+ if (!messageBody || !String(messageBody).trim()) {
257
+ return json({ success: false, error: 'Message body is required.' }, { status: 400 });
258
+ }
259
+ const sessionId = identifier || session_id || readCookie(request, SESSION_COOKIE);
260
+ if (!sessionId) {
261
+ return json({ success: false, error: 'identifier (session) is required.' }, { status: 400 });
262
+ }
263
+
264
+ const ctx = clientContext(request);
265
+ const response = await fetch(`${API_URL}/public/chat/messages`, {
266
+ method: 'POST',
267
+ headers: {
268
+ 'Content-Type': 'application/json',
269
+ 'X-API-Key': API_KEY,
270
+ ...ctx.headers,
271
+ },
272
+ body: JSON.stringify({
273
+ session_id: sessionId,
274
+ body: messageBody,
275
+ display_name,
276
+ email,
277
+ phone,
278
+ first_name,
279
+ last_name,
280
+ page_url,
281
+ fbp: ctx.fbp,
282
+ fbc: ctx.fbc,
283
+ }),
284
+ });
285
+
286
+ const data = await response.json().catch(() => ({}));
287
+ if (!response.ok) {
288
+ return json(
289
+ { success: false, error: data?.message || 'Failed to send message. Please try again.' },
290
+ { status: response.status }
291
+ );
292
+ }
293
+
294
+ // Return a truthy `job_id` so the widget long-polls for the async AI
295
+ // reply — sentinel fallback because a falsy job_id would silently skip
296
+ // the reply wait (sor always dispatches the AI async).
297
+ const cursor = data?.data?.cursor ?? null;
298
+ return json(
299
+ { success: true, data: { job_id: cursor ?? 'pending', cursor } },
300
+ { headers: { 'Set-Cookie': sessionCookieHeader(sessionId) } }
301
+ );
302
+ } catch (error) {
303
+ serverError('CHAT_ROUTE_POST_ERROR', error);
304
+ return json({ success: false, error: 'Network error. Please try again later.' }, { status: 500 });
305
+ }
306
+ },
307
+
308
+ // GET /api/chat?identifier=... — load message history (flat array, widget shape)
309
+ GET: async (request: Request): Promise<Response> => {
310
+ try {
311
+ const { searchParams } = new URL(request.url);
312
+
313
+ // No ActionCable on sor-service: answer the widget's realtime probe with
314
+ // an empty payload (no cable_url) so it falls back to polling.
315
+ if (searchParams.get('action') === 'realtime_token') {
316
+ return json({ success: true, data: {} });
317
+ }
318
+
319
+ const sessionId =
320
+ searchParams.get('identifier') ||
321
+ searchParams.get('session_id') ||
322
+ readCookie(request, SESSION_COOKIE);
323
+
324
+ if (!sessionId) {
325
+ return json({ success: true, data: [] });
326
+ }
327
+
328
+ const query = new URLSearchParams({ session_id: sessionId, wait: '0' });
329
+ const since = searchParams.get('since');
330
+ if (since) query.set('since', since);
331
+
332
+ const response = await fetch(`${API_URL}/public/chat/messages?${query.toString()}`, {
333
+ headers: { 'X-API-Key': API_KEY },
334
+ });
335
+
336
+ const data = await response.json().catch(() => ({}));
337
+ if (!response.ok) {
338
+ return json(
339
+ { success: false, error: data?.message || 'Failed to load messages.' },
340
+ { status: response.status }
341
+ );
342
+ }
343
+
344
+ const messages = (data?.data?.messages ?? []) as SorMessage[];
345
+ return json({ success: true, data: messages.map(toWidgetMessage) });
346
+ } catch (error) {
347
+ serverError('CHAT_ROUTE_GET_ERROR', error);
348
+ return json({ success: false, error: 'Network error. Please try again later.' }, { status: 500 });
349
+ }
350
+ },
351
+ };
352
+ }
353
+
354
+ export function createChatRouteHandlers(deps?: {
355
+ NextResponse?: { json: JsonResponder };
356
+ backend?: ChatBackend;
357
+ }) {
358
+ const json: JsonResponder = deps?.NextResponse?.json ?? ((body, init) => Response.json(body, init));
359
+ const backend: ChatBackend = deps?.backend ?? resolveChatBackend();
360
+
361
+ return backend === SOR_BACKEND ? createSorHandlers(json) : createRailsHandlers(json);
362
+ }