@brandfine/client 0.11.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @brandfine/client
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6c8f190: Headless Live Chat — build your own chat UI:
8
+ - `liveChat.createSession({ config, visitor, locale, pollIntervalMs })` exposes the conversation as typed, subscribable state (`messages`, `status`, `online`, localized `greeting`/`offlineMessage`, `sending`, `error`) with `send()`, `subscribe()` (shaped for `useSyncExternalStore`), `close()` and `reset()`. Shares the floating widget's storage, threading and identity semantics — same conversation everywhere; the widget remains unchanged.
9
+ - Raw wire methods for full control: `liveChat.runtimeConfig()`, `startConversation()`, `sendMessage()`, `history()`. In browsers, construct the client with the publishable key.
10
+ - New root exports: `ChatMessage`, `ChatMessageSender`, `ConversationStatus`, `LiveChatSession`, `LiveChatSessionSnapshot`, `LiveChatSessionState`, `LiveChatCreateSessionOptions`, `LiveChatRuntimeConfig`, `StartConversationResult` — with a root-export test so gaps can't regress again.
11
+ - Transport is polling (default 5000 ms, `pollIntervalMs` option); a future realtime upgrade keeps this API with polling as fallback.
12
+
13
+ ## 0.12.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 0e6c7c9: Live Chat localization + zero-config identity signing (code already on main, changeset restored after a merge dropped it):
18
+ - `liveChat.install({ config, locale })` pins the widget's language (falls back to the page's `<html lang>`, then the browser). Greeting/away-message translations are configured per locale in the CMS and resolved server-side; the visitor's page language is stored on the conversation.
19
+ - `liveChat.identityToken()` no longer requires a configured secret: by default it derives the signing secret from the client's API key (domain-separated HMAC; the API verifies against the same derivation). `{ secret }` / `BRANDFINE_LIVE_CHAT_IDENTITY_SECRET` still override for decoupled signers and legacy workspaces. The missing-secret throw is gone — only the in-browser guard remains.
20
+
3
21
  ## 0.11.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -106,16 +106,13 @@ bf.liveChat.install({ config })
106
106
 
107
107
  ### Verified visitors
108
108
 
109
- On signed-in pages, tell the inbox **who** is chatting. Sign the
110
- identity on your server with the workspace's identity secret (CMS:
111
- Plugins Live Chat Manage settings Integrate), then pass it to
112
- `install()`:
109
+ On signed-in pages, tell the inbox **who** is chatting. Signing
110
+ needs **no extra configuration** `identityToken()` derives the
111
+ signing secret from the API key the client already holds:
113
112
 
114
113
  ```ts
115
114
  // Server — never in a browser:
116
- const identityToken = await bf.liveChat.identityToken(user.id, {
117
- secret: process.env.BRANDFINE_LIVE_CHAT_IDENTITY_SECRET,
118
- })
115
+ const identityToken = await bf.liveChat.identityToken(user.id)
119
116
  const visitor = {
120
117
  externalId: user.id,
121
118
  name: user.name,
@@ -133,11 +130,13 @@ the Brandfine inbox and continue across devices/sessions (same
133
130
  `externalId` = same person). An invalid or missing token silently
134
131
  downgrades to anonymous chat — never a blocked visitor.
135
132
 
136
- > **Security:** never ship the identity secret to a browser and
137
- > never compute the HMAC client-side either would let anyone
138
- > impersonate any visitor. `identityToken()` enforces this: it
139
- > throws in browser contexts and when the secret is missing. Rotate
140
- > the secret in the CMS if it ever leaks.
133
+ > **Security:** never compute the HMAC client-side the signing
134
+ > secret (derived from your API key, or an explicit one) must never
135
+ > reach a browser, or anyone could impersonate any visitor.
136
+ > `identityToken()` enforces this by throwing in browser contexts.
137
+ > An explicit secret (`{ secret }` / `BRANDFINE_LIVE_CHAT_IDENTITY_SECRET`)
138
+ > overrides the derived default — only needed when the signer
139
+ > shouldn't hold the broad API key.
141
140
 
142
141
  Full reference: [docs.brandfine.co/docs/sdk/live-chat](https://docs.brandfine.co/docs/sdk/live-chat).
143
142
 
package/dist/index.cjs CHANGED
@@ -4,6 +4,201 @@ var chunkXJFKL2HU_cjs = require('./chunk-XJFKL2HU.cjs');
4
4
  var chunkFCK7QJBC_cjs = require('./chunk-FCK7QJBC.cjs');
5
5
  var chunkKHHMR2NX_cjs = require('./chunk-KHHMR2NX.cjs');
6
6
 
7
+ // src/live-chat-session.ts
8
+ var SESSION_STORAGE_KEY = "bf-live-chat-session";
9
+ function tokenStorageKey(publishableKey) {
10
+ return `bf-live-chat-token:${publishableKey.slice(-12)}`;
11
+ }
12
+ function getVisitorSessionId() {
13
+ try {
14
+ const existing = localStorage.getItem(SESSION_STORAGE_KEY);
15
+ if (existing) return existing;
16
+ const generated = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "") : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
17
+ localStorage.setItem(SESSION_STORAGE_KEY, generated);
18
+ return generated;
19
+ } catch {
20
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
21
+ }
22
+ }
23
+ function readStoredToken(publishableKey) {
24
+ try {
25
+ return localStorage.getItem(tokenStorageKey(publishableKey)) ?? void 0;
26
+ } catch {
27
+ return void 0;
28
+ }
29
+ }
30
+ function writeStoredToken(publishableKey, token) {
31
+ try {
32
+ if (token === null) localStorage.removeItem(tokenStorageKey(publishableKey));
33
+ else localStorage.setItem(tokenStorageKey(publishableKey), token);
34
+ } catch {
35
+ }
36
+ }
37
+ function resolvePageLocale(explicit) {
38
+ if (explicit) return explicit;
39
+ if (typeof document === "undefined") return void 0;
40
+ const htmlLang = document.documentElement.lang?.trim();
41
+ if (htmlLang) return htmlLang;
42
+ return typeof navigator !== "undefined" ? navigator.language || void 0 : void 0;
43
+ }
44
+ async function createLiveChatSession(wire, opts) {
45
+ if (typeof window === "undefined") {
46
+ throw new Error(
47
+ "liveChat.createSession() is browser-only \u2014 build the transcript UI client-side; fetch the bootstrap with getConfig() on the server."
48
+ );
49
+ }
50
+ const publishableKey = opts.config.enabled ? opts.config.publishableKey : void 0;
51
+ const locale = resolvePageLocale(opts.locale);
52
+ const pollMs = Math.max(1e3, opts.pollIntervalMs ?? 5e3);
53
+ let snapshot = {
54
+ messages: [],
55
+ status: "CONNECTING",
56
+ online: false,
57
+ greeting: null,
58
+ offlineMessage: null,
59
+ sending: false,
60
+ error: null
61
+ };
62
+ const listeners = /* @__PURE__ */ new Set();
63
+ const seen = /* @__PURE__ */ new Set();
64
+ let cursor;
65
+ let token;
66
+ let timer;
67
+ let closed = false;
68
+ let ticking = false;
69
+ const aborter = new AbortController();
70
+ function emit(patch) {
71
+ snapshot = { ...snapshot, ...patch };
72
+ for (const listener of listeners) listener(snapshot);
73
+ }
74
+ function appendMessages(incoming) {
75
+ const fresh = incoming.filter((m) => !seen.has(m.id));
76
+ if (fresh.length === 0) return;
77
+ for (const m of fresh) seen.add(m.id);
78
+ const messages = [...snapshot.messages, ...fresh];
79
+ cursor = messages[messages.length - 1].createdAt;
80
+ emit({ messages });
81
+ }
82
+ async function tick() {
83
+ if (ticking || closed || !token) return;
84
+ ticking = true;
85
+ try {
86
+ const result = await wire.history(token, { after: cursor });
87
+ if (closed) return;
88
+ appendMessages(result.messages);
89
+ if (result.status === "CLOSED" && snapshot.status !== "CLOSED") {
90
+ emit({ status: "CLOSED" });
91
+ stopPolling();
92
+ }
93
+ } catch {
94
+ } finally {
95
+ ticking = false;
96
+ }
97
+ }
98
+ function startPolling() {
99
+ stopPolling();
100
+ timer = setInterval(() => void tick(), pollMs);
101
+ }
102
+ function stopPolling() {
103
+ if (timer !== void 0) clearInterval(timer);
104
+ timer = void 0;
105
+ }
106
+ async function connect(resumeToken) {
107
+ if (!publishableKey) {
108
+ emit({ status: "CLOSED" });
109
+ return;
110
+ }
111
+ try {
112
+ const cfg = await wire.runtimeConfig(locale);
113
+ if (closed) return;
114
+ if (!cfg.enabled) {
115
+ emit({ status: "CLOSED" });
116
+ return;
117
+ }
118
+ emit({
119
+ greeting: cfg.greeting,
120
+ offlineMessage: cfg.offlineMessage,
121
+ online: cfg.online !== false
122
+ });
123
+ } catch (error) {
124
+ if (closed) return;
125
+ emit({ status: "ERROR", error });
126
+ return;
127
+ }
128
+ try {
129
+ const started = await wire.startConversation({
130
+ visitorSessionId: getVisitorSessionId(),
131
+ conversationToken: resumeToken,
132
+ pageUrl: opts.pageUrl ?? (typeof location !== "undefined" ? location.href.slice(0, 2048) : void 0),
133
+ referrer: opts.referrer,
134
+ locale,
135
+ visitor: opts.visitor
136
+ });
137
+ if (closed) return;
138
+ if (!started.enabled) {
139
+ emit({ status: "CLOSED" });
140
+ return;
141
+ }
142
+ token = started.conversationToken;
143
+ writeStoredToken(publishableKey, token);
144
+ if (started.online !== void 0) emit({ online: started.online });
145
+ emit({ status: "OPEN" });
146
+ await tick();
147
+ if (!closed) startPolling();
148
+ } catch (error) {
149
+ if (closed) return;
150
+ emit({ status: "ERROR", error });
151
+ }
152
+ }
153
+ const session = {
154
+ get messages() {
155
+ return snapshot.messages;
156
+ },
157
+ get state() {
158
+ return snapshot.status;
159
+ },
160
+ getSnapshot: () => snapshot,
161
+ subscribe(listener) {
162
+ listeners.add(listener);
163
+ return () => listeners.delete(listener);
164
+ },
165
+ async send(body) {
166
+ const trimmed = body.trim();
167
+ if (!trimmed) throw new Error("send(): message body is empty");
168
+ if (!token) throw new Error("send(): session is not connected");
169
+ if (snapshot.status === "CLOSED") {
170
+ throw new Error("send(): conversation is closed \u2014 call reset()");
171
+ }
172
+ emit({ sending: true });
173
+ try {
174
+ const message = await wire.sendMessage(token, { body: trimmed });
175
+ appendMessages([message]);
176
+ return message;
177
+ } finally {
178
+ emit({ sending: false });
179
+ }
180
+ },
181
+ close() {
182
+ if (closed) return;
183
+ closed = true;
184
+ stopPolling();
185
+ aborter.abort();
186
+ listeners.clear();
187
+ },
188
+ async reset() {
189
+ if (publishableKey) writeStoredToken(publishableKey, null);
190
+ token = void 0;
191
+ cursor = void 0;
192
+ seen.clear();
193
+ stopPolling();
194
+ emit({ messages: [], status: "CONNECTING", error: null });
195
+ await connect(void 0);
196
+ }
197
+ };
198
+ await connect(publishableKey ? readStoredToken(publishableKey) : void 0);
199
+ return session;
200
+ }
201
+
7
202
  // src/client.ts
8
203
  var BrandfineApiError = class extends Error {
9
204
  name = "BrandfineApiError";
@@ -23,6 +218,23 @@ var BrandfineApiError = class extends Error {
23
218
  };
24
219
  var INSTALLED_MARKER = "data-brandfine-analytics";
25
220
  var LIVE_CHAT_MARKER = "data-brandfine-live-chat";
221
+ var LIVE_CHAT_IDENTITY_CONTEXT = "brandfine:live-chat:identity:v1";
222
+ async function hmacHex(key, message) {
223
+ const enc = new TextEncoder();
224
+ const cryptoKey = await globalThis.crypto.subtle.importKey(
225
+ "raw",
226
+ enc.encode(key),
227
+ { name: "HMAC", hash: "SHA-256" },
228
+ false,
229
+ ["sign"]
230
+ );
231
+ const sig = await globalThis.crypto.subtle.sign(
232
+ "HMAC",
233
+ cryptoKey,
234
+ enc.encode(message)
235
+ );
236
+ return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
237
+ }
26
238
  var GTAG_MARKER = "data-brandfine-gtag";
27
239
  function injectGoogleTag(measurementId) {
28
240
  if (typeof document === "undefined") return;
@@ -170,6 +382,28 @@ function createBrandfineClient(config) {
170
382
  return { installed: true, websiteId: cfg.websiteId };
171
383
  }
172
384
  };
385
+ async function liveChatWireRequest(key, method, path, body) {
386
+ const url = `${baseUrl}${path}`;
387
+ const res = await fetchImpl(url, {
388
+ method,
389
+ headers: {
390
+ Accept: "application/json",
391
+ ...body !== void 0 ? { "Content-Type": "application/json" } : {},
392
+ ...key ? { "X-Api-Key": key } : {}
393
+ },
394
+ body: body !== void 0 ? JSON.stringify(body) : void 0
395
+ });
396
+ if (!res.ok) {
397
+ const text = await res.text().catch(() => "");
398
+ throw new BrandfineApiError({
399
+ status: res.status,
400
+ statusText: res.statusText,
401
+ body: text,
402
+ url
403
+ });
404
+ }
405
+ return await res.json();
406
+ }
173
407
  const liveChat = {
174
408
  getConfig() {
175
409
  return get("/external/live-chat/bootstrap");
@@ -194,6 +428,9 @@ function createBrandfineClient(config) {
194
428
  if (opts.visitor?.externalId && opts.visitor.identityToken) {
195
429
  host.setAttribute("data-visitor", JSON.stringify(opts.visitor));
196
430
  }
431
+ if (opts.locale) {
432
+ host.setAttribute("data-locale", opts.locale);
433
+ }
197
434
  if (cfg.theme) {
198
435
  for (const [key, value] of Object.entries(cfg.theme)) {
199
436
  if (key.startsWith("--bf-chat-")) {
@@ -209,35 +446,66 @@ function createBrandfineClient(config) {
209
446
  document.head.appendChild(script);
210
447
  return { installed: true };
211
448
  },
449
+ createSession(opts) {
450
+ const pk = opts.config.enabled ? opts.config.publishableKey : void 0;
451
+ const wire = {
452
+ runtimeConfig: (locale) => liveChatWireRequest(
453
+ pk,
454
+ "GET",
455
+ `/external/live-chat/config${locale ? `?locale=${encodeURIComponent(locale)}` : ""}`
456
+ ),
457
+ startConversation: (input) => liveChatWireRequest(pk, "POST", "/external/live-chat/conversations", input),
458
+ sendMessage: (conversationToken, input) => liveChatWireRequest(
459
+ void 0,
460
+ "POST",
461
+ `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages`,
462
+ input
463
+ ),
464
+ history: (conversationToken, o = {}) => liveChatWireRequest(
465
+ void 0,
466
+ "GET",
467
+ `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${o.after ? `?after=${encodeURIComponent(o.after)}` : ""}`
468
+ )
469
+ };
470
+ return createLiveChatSession(wire, opts);
471
+ },
472
+ runtimeConfig(locale) {
473
+ return liveChatWireRequest(
474
+ apiKey,
475
+ "GET",
476
+ `/external/live-chat/config${locale ? `?locale=${encodeURIComponent(locale)}` : ""}`
477
+ );
478
+ },
479
+ startConversation(input) {
480
+ return liveChatWireRequest(apiKey, "POST", "/external/live-chat/conversations", input);
481
+ },
482
+ sendMessage(conversationToken, input) {
483
+ return liveChatWireRequest(
484
+ void 0,
485
+ "POST",
486
+ `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages`,
487
+ input
488
+ );
489
+ },
490
+ history(conversationToken, o = {}) {
491
+ return liveChatWireRequest(
492
+ void 0,
493
+ "GET",
494
+ `/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${o.after ? `?after=${encodeURIComponent(o.after)}` : ""}`
495
+ );
496
+ },
212
497
  async identityToken(externalId, opts = {}) {
213
498
  if (typeof document !== "undefined" || typeof window !== "undefined") {
214
499
  throw new Error(
215
500
  "liveChat.identityToken() is server-only \u2014 never compute identity tokens in a browser. Sign the visitor on your server and pass the result to install({ visitor })."
216
501
  );
217
502
  }
218
- const secret = opts.secret ?? (typeof process !== "undefined" ? process.env.BRANDFINE_LIVE_CHAT_IDENTITY_SECRET : void 0);
219
- if (!secret) {
220
- throw new Error(
221
- "liveChat.identityToken(): identity secret missing. Pass { secret } or set BRANDFINE_LIVE_CHAT_IDENTITY_SECRET. Generate one in the CMS: Plugins \u2192 Live Chat \u2192 Integrate."
222
- );
223
- }
224
503
  if (!externalId) {
225
504
  throw new Error("liveChat.identityToken(): externalId is required.");
226
505
  }
227
- const enc = new TextEncoder();
228
- const key = await globalThis.crypto.subtle.importKey(
229
- "raw",
230
- enc.encode(secret),
231
- { name: "HMAC", hash: "SHA-256" },
232
- false,
233
- ["sign"]
234
- );
235
- const sig = await globalThis.crypto.subtle.sign(
236
- "HMAC",
237
- key,
238
- enc.encode(externalId)
239
- );
240
- return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
506
+ const explicit = opts.secret ?? (typeof process !== "undefined" ? process.env.BRANDFINE_LIVE_CHAT_IDENTITY_SECRET : void 0);
507
+ const secret = explicit ?? await hmacHex(apiKey, LIVE_CHAT_IDENTITY_CONTEXT);
508
+ return hmacHex(secret, externalId);
241
509
  }
242
510
  };
243
511
  const submissions = {