@brandfine/client 0.13.0 → 0.14.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 +19 -0
- package/README.md +11 -0
- package/dist/index.cjs +196 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +92 -6
- package/dist/index.d.ts +92 -6
- package/dist/index.js +196 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @brandfine/client
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 966879a: Appointments grows the authenticated half it was missing — book as a known person, read your own status back, and cancel, mirroring Live Chat's identity model:
|
|
8
|
+
- **Verified visitor identity on `createRequest`** — optional `visitor: { externalId, name?, email?, attributes?, identityToken }`, signed with the SAME `liveChat.identityToken(externalId)` you already use for chat (one signature, both plugins). Valid → the booking is attributed (verified name/email take precedence, `identityVerified: true` in the response, and the booking links to the person's chat threads via the shared `externalId`). Invalid or absent → the booking proceeds anonymously; it is never rejected.
|
|
9
|
+
- **Identity-scoped read-back** — `appointments.list({ externalId, identityToken })` returns the verified person's appointments (status, requestedAt, `scheduledAt` once confirmed, `declineReason` on rejection, timezone), newest first; `appointments.get(id, identity)` fetches one, 404ing identically for missing and foreign ids. POST under the hood so the identityToken never lands in URLs or logs. Bad signature → hard 401 (reads require proof).
|
|
10
|
+
- **`appointments.cancel(cancellationToken)`** — spends the one-time token `createRequest` already returned; no more hand-rolled HTTP call.
|
|
11
|
+
- **Publishable-key availability** — the workspace's scoped `brandfine_pk_…` key (minted/upgraded on Appointments activation, shown in the config) can now call `getAvailability` read-only, so fully-static sites render slots without a proxy. Booking, read-back and cancel stay on the broad workspace key.
|
|
12
|
+
- **Export fix** — `AppointmentAvailability`, `AppointmentSlot`, `CreateAppointmentRequestInput`, `CreatedAppointmentRequest` (plus the new `Appointment`, `AppointmentStatus`, `AppointmentIdentity`, `VerifiedVisitor`) are now exported from the package root; previously they were declared but unexported.
|
|
13
|
+
|
|
14
|
+
- 966879a: Live Chat gets a realtime transport — messages arrive in ~ms instead of on the 5-second tick, with **zero consumer code changes**:
|
|
15
|
+
- `createSession()` upgrades itself to a WebSocket when the API advertises `realtimeUrl` (new optional field on `LiveChatBootstrap` and the runtime config — older SDKs ignore it; this SDK against an older API simply polls). Same `subscribe`/`getSnapshot`/`send`/`reset`/`close` surface; a consumer never has to ask which transport is live.
|
|
16
|
+
- **Polling is the automatic, invisible fallback**: no `realtimeUrl`, no `WebSocket` in the runtime, a socket that won't open, or 5 consecutive reconnect failures → the session polls exactly as before. During reconnect backoff (1s→30s with jitter) polling covers the gap from the first millisecond.
|
|
17
|
+
- **Resume by `eventId`**: every message now carries a monotonic per-conversation `eventId`; reconnects replay everything after the last one seen — a laptop waking from sleep gets the missed replies, no gaps and no duplicates (also fixes a rare equal-millisecond drop window in the polling cursor via the new `afterEvent` history param).
|
|
18
|
+
- **Idempotent sends**: `send()` attaches a generated `clientId`; a retry after a dropped response returns the original message instead of duplicating.
|
|
19
|
+
- Socket auth mirrors REST exactly (publishable key + conversation token, sent in the first frame — never in the URL) and grants nothing REST wouldn't. `reset()` tears the socket down before switching threads (shared-browser guarantee); unknown frame types are ignored by both ends so typing/presence/read receipts can ship later without a breaking change.
|
|
20
|
+
- Workspaces can optionally restrict socket origins via the new `allowedOrigins` Live Chat setting (unset = any origin, matching the REST posture).
|
|
21
|
+
|
|
3
22
|
## 0.13.0
|
|
4
23
|
|
|
5
24
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -62,6 +62,17 @@ await bf.appointments.createRequest({
|
|
|
62
62
|
visitorName: 'Alex',
|
|
63
63
|
visitorEmail: 'alex@example.com',
|
|
64
64
|
requestedAt: slots[0].start,
|
|
65
|
+
// Optional: book as a KNOWN person — same signature Live Chat
|
|
66
|
+
// uses, so one identityToken serves both plugins. Unlocks
|
|
67
|
+
// list()/get() read-back for that person.
|
|
68
|
+
visitor: {
|
|
69
|
+
externalId: 'user:42',
|
|
70
|
+
identityToken: await bf.liveChat.identityToken('user:42'),
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
const mine = await bf.appointments.list({
|
|
74
|
+
externalId: 'user:42',
|
|
75
|
+
identityToken: await bf.liveChat.identityToken('user:42'),
|
|
65
76
|
})
|
|
66
77
|
```
|
|
67
78
|
|
package/dist/index.cjs
CHANGED
|
@@ -62,34 +62,63 @@ async function createLiveChatSession(wire, opts) {
|
|
|
62
62
|
const listeners = /* @__PURE__ */ new Set();
|
|
63
63
|
const seen = /* @__PURE__ */ new Set();
|
|
64
64
|
let cursor;
|
|
65
|
+
let lastEventId = 0;
|
|
65
66
|
let token;
|
|
66
67
|
let timer;
|
|
67
68
|
let closed = false;
|
|
68
69
|
let ticking = false;
|
|
69
70
|
const aborter = new AbortController();
|
|
71
|
+
let realtimeUrl = opts.config.realtimeUrl;
|
|
72
|
+
let ws;
|
|
73
|
+
let wsLive = false;
|
|
74
|
+
let wsFailures = 0;
|
|
75
|
+
let wsGivenUp = false;
|
|
76
|
+
let reconnectTimer;
|
|
77
|
+
let pingTimer;
|
|
70
78
|
function emit(patch) {
|
|
71
79
|
snapshot = { ...snapshot, ...patch };
|
|
72
80
|
for (const listener of listeners) listener(snapshot);
|
|
73
81
|
}
|
|
74
82
|
function appendMessages(incoming) {
|
|
75
83
|
const fresh = incoming.filter((m) => !seen.has(m.id));
|
|
76
|
-
if (fresh.length === 0)
|
|
77
|
-
|
|
84
|
+
if (fresh.length === 0) {
|
|
85
|
+
for (const m of incoming) {
|
|
86
|
+
if (typeof m.eventId === "number" && m.eventId > lastEventId) {
|
|
87
|
+
lastEventId = m.eventId;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const m of fresh) {
|
|
93
|
+
seen.add(m.id);
|
|
94
|
+
if (typeof m.eventId === "number" && m.eventId > lastEventId) {
|
|
95
|
+
lastEventId = m.eventId;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
78
98
|
const messages = [...snapshot.messages, ...fresh];
|
|
79
99
|
cursor = messages[messages.length - 1].createdAt;
|
|
80
100
|
emit({ messages });
|
|
81
101
|
}
|
|
102
|
+
function markClosed() {
|
|
103
|
+
if (snapshot.status !== "CLOSED") emit({ status: "CLOSED" });
|
|
104
|
+
stopPolling();
|
|
105
|
+
teardownRealtime();
|
|
106
|
+
wsGivenUp = true;
|
|
107
|
+
}
|
|
82
108
|
async function tick() {
|
|
83
109
|
if (ticking || closed || !token) return;
|
|
84
110
|
ticking = true;
|
|
85
111
|
try {
|
|
86
|
-
const result = await wire.history(
|
|
112
|
+
const result = await wire.history(
|
|
113
|
+
token,
|
|
114
|
+
// The eventId cursor is exact (no equal-millisecond drop
|
|
115
|
+
// window); the timestamp cursor remains for old APIs that
|
|
116
|
+
// never sent eventIds.
|
|
117
|
+
lastEventId > 0 ? { afterEvent: lastEventId } : { after: cursor }
|
|
118
|
+
);
|
|
87
119
|
if (closed) return;
|
|
88
120
|
appendMessages(result.messages);
|
|
89
|
-
if (result.status === "CLOSED"
|
|
90
|
-
emit({ status: "CLOSED" });
|
|
91
|
-
stopPolling();
|
|
92
|
-
}
|
|
121
|
+
if (result.status === "CLOSED") markClosed();
|
|
93
122
|
} catch {
|
|
94
123
|
} finally {
|
|
95
124
|
ticking = false;
|
|
@@ -103,6 +132,119 @@ async function createLiveChatSession(wire, opts) {
|
|
|
103
132
|
if (timer !== void 0) clearInterval(timer);
|
|
104
133
|
timer = void 0;
|
|
105
134
|
}
|
|
135
|
+
function teardownRealtime() {
|
|
136
|
+
if (reconnectTimer !== void 0) clearTimeout(reconnectTimer);
|
|
137
|
+
reconnectTimer = void 0;
|
|
138
|
+
if (pingTimer !== void 0) clearInterval(pingTimer);
|
|
139
|
+
pingTimer = void 0;
|
|
140
|
+
wsLive = false;
|
|
141
|
+
if (ws) {
|
|
142
|
+
const socket = ws;
|
|
143
|
+
ws = void 0;
|
|
144
|
+
try {
|
|
145
|
+
socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null;
|
|
146
|
+
socket.close();
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function scheduleReconnect() {
|
|
152
|
+
if (closed || wsGivenUp || reconnectTimer !== void 0) return;
|
|
153
|
+
if (wsFailures >= 5) {
|
|
154
|
+
wsGivenUp = true;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const base = Math.min(3e4, 1e3 * 2 ** wsFailures);
|
|
158
|
+
const delay = base / 2 + Math.random() * (base / 2);
|
|
159
|
+
reconnectTimer = setTimeout(() => {
|
|
160
|
+
reconnectTimer = void 0;
|
|
161
|
+
startRealtime();
|
|
162
|
+
}, delay);
|
|
163
|
+
}
|
|
164
|
+
function handleSocketDown() {
|
|
165
|
+
teardownRealtime();
|
|
166
|
+
if (closed || wsGivenUp || snapshot.status === "CLOSED") return;
|
|
167
|
+
startPolling();
|
|
168
|
+
wsFailures += 1;
|
|
169
|
+
scheduleReconnect();
|
|
170
|
+
}
|
|
171
|
+
function startRealtime() {
|
|
172
|
+
if (closed || wsGivenUp || wsLive || ws !== void 0 || !realtimeUrl || !token || !publishableKey || typeof WebSocket === "undefined" || snapshot.status === "CLOSED") {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
let socket;
|
|
176
|
+
try {
|
|
177
|
+
socket = new WebSocket(realtimeUrl);
|
|
178
|
+
} catch {
|
|
179
|
+
wsFailures += 1;
|
|
180
|
+
scheduleReconnect();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
ws = socket;
|
|
184
|
+
socket.onopen = () => {
|
|
185
|
+
try {
|
|
186
|
+
socket.send(
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
type: "auth",
|
|
189
|
+
publishableKey,
|
|
190
|
+
conversationToken: token,
|
|
191
|
+
lastEventId
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
} catch {
|
|
195
|
+
handleSocketDown();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
socket.onmessage = (event) => {
|
|
199
|
+
let frame;
|
|
200
|
+
try {
|
|
201
|
+
frame = JSON.parse(String(event.data));
|
|
202
|
+
} catch {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
switch (frame.type) {
|
|
206
|
+
case "ready": {
|
|
207
|
+
wsLive = true;
|
|
208
|
+
wsFailures = 0;
|
|
209
|
+
stopPolling();
|
|
210
|
+
if (pingTimer === void 0) {
|
|
211
|
+
pingTimer = setInterval(() => {
|
|
212
|
+
try {
|
|
213
|
+
socket.send(JSON.stringify({ type: "ping" }));
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
}, 3e4);
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case "message": {
|
|
221
|
+
const m = frame.message;
|
|
222
|
+
if (m && typeof m.id === "string") {
|
|
223
|
+
appendMessages([
|
|
224
|
+
{
|
|
225
|
+
...m,
|
|
226
|
+
eventId: typeof frame.eventId === "number" ? frame.eventId : m.eventId
|
|
227
|
+
}
|
|
228
|
+
]);
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case "status": {
|
|
233
|
+
if (frame.status === "CLOSED") markClosed();
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case "error": {
|
|
237
|
+
wsGivenUp = true;
|
|
238
|
+
teardownRealtime();
|
|
239
|
+
if (!closed && snapshot.status !== "CLOSED") startPolling();
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
socket.onclose = () => handleSocketDown();
|
|
245
|
+
socket.onerror = () => {
|
|
246
|
+
};
|
|
247
|
+
}
|
|
106
248
|
async function connect(resumeToken) {
|
|
107
249
|
if (!publishableKey) {
|
|
108
250
|
emit({ status: "CLOSED" });
|
|
@@ -120,6 +262,7 @@ async function createLiveChatSession(wire, opts) {
|
|
|
120
262
|
offlineMessage: cfg.offlineMessage,
|
|
121
263
|
online: cfg.online !== false
|
|
122
264
|
});
|
|
265
|
+
if (cfg.realtimeUrl !== void 0) realtimeUrl = cfg.realtimeUrl;
|
|
123
266
|
} catch (error) {
|
|
124
267
|
if (closed) return;
|
|
125
268
|
emit({ status: "ERROR", error });
|
|
@@ -144,7 +287,10 @@ async function createLiveChatSession(wire, opts) {
|
|
|
144
287
|
if (started.online !== void 0) emit({ online: started.online });
|
|
145
288
|
emit({ status: "OPEN" });
|
|
146
289
|
await tick();
|
|
147
|
-
if (!closed)
|
|
290
|
+
if (!closed) {
|
|
291
|
+
startPolling();
|
|
292
|
+
startRealtime();
|
|
293
|
+
}
|
|
148
294
|
} catch (error) {
|
|
149
295
|
if (closed) return;
|
|
150
296
|
emit({ status: "ERROR", error });
|
|
@@ -171,7 +317,11 @@ async function createLiveChatSession(wire, opts) {
|
|
|
171
317
|
}
|
|
172
318
|
emit({ sending: true });
|
|
173
319
|
try {
|
|
174
|
-
const
|
|
320
|
+
const clientId = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
|
321
|
+
const message = await wire.sendMessage(token, {
|
|
322
|
+
body: trimmed,
|
|
323
|
+
clientId
|
|
324
|
+
});
|
|
175
325
|
appendMessages([message]);
|
|
176
326
|
return message;
|
|
177
327
|
} finally {
|
|
@@ -182,13 +332,18 @@ async function createLiveChatSession(wire, opts) {
|
|
|
182
332
|
if (closed) return;
|
|
183
333
|
closed = true;
|
|
184
334
|
stopPolling();
|
|
335
|
+
teardownRealtime();
|
|
185
336
|
aborter.abort();
|
|
186
337
|
listeners.clear();
|
|
187
338
|
},
|
|
188
339
|
async reset() {
|
|
340
|
+
teardownRealtime();
|
|
341
|
+
wsFailures = 0;
|
|
342
|
+
wsGivenUp = false;
|
|
189
343
|
if (publishableKey) writeStoredToken(publishableKey, null);
|
|
190
344
|
token = void 0;
|
|
191
345
|
cursor = void 0;
|
|
346
|
+
lastEventId = 0;
|
|
192
347
|
seen.clear();
|
|
193
348
|
stopPolling();
|
|
194
349
|
emit({ messages: [], status: "CONNECTING", error: null });
|
|
@@ -464,7 +619,7 @@ function createBrandfineClient(config) {
|
|
|
464
619
|
history: (conversationToken, o = {}) => liveChatWireRequest(
|
|
465
620
|
void 0,
|
|
466
621
|
"GET",
|
|
467
|
-
`/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${o.after ? `?after=${encodeURIComponent(o.after)}` : ""}`
|
|
622
|
+
`/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${o.afterEvent !== void 0 ? `?afterEvent=${encodeURIComponent(String(o.afterEvent))}` : o.after ? `?after=${encodeURIComponent(o.after)}` : ""}`
|
|
468
623
|
)
|
|
469
624
|
};
|
|
470
625
|
return createLiveChatSession(wire, opts);
|
|
@@ -488,10 +643,11 @@ function createBrandfineClient(config) {
|
|
|
488
643
|
);
|
|
489
644
|
},
|
|
490
645
|
history(conversationToken, o = {}) {
|
|
646
|
+
const qs = o.afterEvent !== void 0 ? `?afterEvent=${encodeURIComponent(String(o.afterEvent))}` : o.after ? `?after=${encodeURIComponent(o.after)}` : "";
|
|
491
647
|
return liveChatWireRequest(
|
|
492
648
|
void 0,
|
|
493
649
|
"GET",
|
|
494
|
-
`/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${
|
|
650
|
+
`/external/live-chat/conversations/${encodeURIComponent(conversationToken)}/messages${qs}`
|
|
495
651
|
);
|
|
496
652
|
},
|
|
497
653
|
async identityToken(externalId, opts = {}) {
|
|
@@ -573,6 +729,35 @@ function createBrandfineClient(config) {
|
|
|
573
729
|
"/external/appointments/requests",
|
|
574
730
|
input
|
|
575
731
|
);
|
|
732
|
+
},
|
|
733
|
+
async list(identity) {
|
|
734
|
+
const res = await post(
|
|
735
|
+
"/external/appointments/requests/lookup",
|
|
736
|
+
identity
|
|
737
|
+
);
|
|
738
|
+
return res.appointments;
|
|
739
|
+
},
|
|
740
|
+
async get(id, identity) {
|
|
741
|
+
const res = await post(
|
|
742
|
+
"/external/appointments/requests/lookup",
|
|
743
|
+
{ ...identity, id }
|
|
744
|
+
);
|
|
745
|
+
const row = res.appointments[0];
|
|
746
|
+
if (!row) {
|
|
747
|
+
throw new BrandfineApiError({
|
|
748
|
+
status: 404,
|
|
749
|
+
statusText: "Not Found",
|
|
750
|
+
body: "Appointment not found.",
|
|
751
|
+
url: "/external/appointments/requests/lookup"
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
return row;
|
|
755
|
+
},
|
|
756
|
+
cancel(cancellationToken) {
|
|
757
|
+
return post(
|
|
758
|
+
`/external/appointments/requests/${encodeURIComponent(cancellationToken)}/cancel`,
|
|
759
|
+
{}
|
|
760
|
+
);
|
|
576
761
|
}
|
|
577
762
|
};
|
|
578
763
|
return {
|