@cosmoops/connect-sdk 0.30.1 → 0.33.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 +267 -0
- package/dist/cjs/client/components/visitor-chat.js +387 -0
- package/dist/cjs/client/components/visitor-stream.js +124 -0
- package/dist/cjs/client/index.js +23 -3
- package/dist/cjs/client/provider.js +24 -0
- package/dist/cjs/client/use-push.js +139 -0
- package/dist/cjs/server/client.js +15 -1
- package/dist/cjs/server/handler.js +196 -0
- package/dist/cjs/server/resources/ai.js +85 -0
- package/dist/cjs/server/resources/enrichment.js +107 -0
- package/dist/cjs/server/resources/push.js +92 -0
- package/dist/cjs/server/resources/visitor.js +227 -0
- package/dist/esm/client/components/visitor-chat.js +384 -0
- package/dist/esm/client/components/visitor-stream.js +120 -0
- package/dist/esm/client/index.js +17 -1
- package/dist/esm/client/provider.js +23 -0
- package/dist/esm/client/use-push.js +136 -0
- package/dist/esm/server/client.js +15 -1
- package/dist/esm/server/handler.js +196 -0
- package/dist/esm/server/resources/ai.js +85 -0
- package/dist/esm/server/resources/enrichment.js +107 -0
- package/dist/esm/server/resources/push.js +88 -0
- package/dist/esm/server/resources/visitor.js +222 -0
- package/dist/types/client/components/visitor-chat.d.ts +120 -0
- package/dist/types/client/components/visitor-stream.d.ts +42 -0
- package/dist/types/client/index.d.ts +13 -1
- package/dist/types/client/provider.d.ts +21 -0
- package/dist/types/client/use-push.d.ts +56 -0
- package/dist/types/server/client.d.ts +13 -1
- package/dist/types/server/handler.d.ts +0 -24
- package/dist/types/server/resources/ai.d.ts +75 -1
- package/dist/types/server/resources/enrichment.d.ts +102 -1
- package/dist/types/server/resources/push.d.ts +73 -0
- package/dist/types/server/resources/visitor.d.ts +200 -0
- package/dist/types/types.d.ts +683 -0
- package/package.json +3 -2
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readVisitorStream = readVisitorStream;
|
|
4
|
+
exports.visitorSlotOfferFrom = visitorSlotOfferFrom;
|
|
5
|
+
async function readVisitorStream(response, handlers) {
|
|
6
|
+
const body = response.body;
|
|
7
|
+
if (!body)
|
|
8
|
+
throw new Error("The assistant returned no reply.");
|
|
9
|
+
const reader = body.pipeThrough(new TextDecoderStream()).getReader();
|
|
10
|
+
let buffer = "";
|
|
11
|
+
for (;;) {
|
|
12
|
+
const { done, value } = await reader.read();
|
|
13
|
+
if (done)
|
|
14
|
+
break;
|
|
15
|
+
buffer += value;
|
|
16
|
+
/*
|
|
17
|
+
* Frames are separated by a blank line, so everything before the last one is
|
|
18
|
+
* complete and whatever follows waits for the next read. Splitting on single
|
|
19
|
+
* newlines would hand a half-received `data:` line to `JSON.parse`, which on
|
|
20
|
+
* a slow connection is most frames.
|
|
21
|
+
*/
|
|
22
|
+
const frames = buffer.split("\n\n");
|
|
23
|
+
buffer = frames.pop() ?? "";
|
|
24
|
+
for (const frame of frames)
|
|
25
|
+
dispatch(frame, handlers);
|
|
26
|
+
}
|
|
27
|
+
// A stream that ends mid-frame has nothing complete left in the buffer; one
|
|
28
|
+
// that ends on a frame boundary with no trailing blank line still has its
|
|
29
|
+
// last frame there, which is the shape a server that closes immediately
|
|
30
|
+
// after `done` produces.
|
|
31
|
+
if (buffer.trim())
|
|
32
|
+
dispatch(buffer, handlers);
|
|
33
|
+
}
|
|
34
|
+
function dispatch(frame, handlers) {
|
|
35
|
+
let event = "";
|
|
36
|
+
let data = "";
|
|
37
|
+
for (const line of frame.split("\n")) {
|
|
38
|
+
if (line.startsWith("event:"))
|
|
39
|
+
event = line.slice(6).trim();
|
|
40
|
+
else if (line.startsWith("data:"))
|
|
41
|
+
data += line.slice(5).trim();
|
|
42
|
+
}
|
|
43
|
+
if (!event || !data)
|
|
44
|
+
return;
|
|
45
|
+
let payload;
|
|
46
|
+
try {
|
|
47
|
+
payload = JSON.parse(data);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/*
|
|
51
|
+
* A frame that cannot be parsed is a frame that cannot be rendered. Dropping
|
|
52
|
+
* it keeps the rest of the reply flowing, which is strictly better than
|
|
53
|
+
* failing a whole turn - already paid for - over one malformed chunk.
|
|
54
|
+
*/
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const record = payload;
|
|
58
|
+
switch (event) {
|
|
59
|
+
case "delta":
|
|
60
|
+
if (typeof record.text === "string")
|
|
61
|
+
handlers.onDelta(record.text);
|
|
62
|
+
return;
|
|
63
|
+
case "done":
|
|
64
|
+
handlers.onDone({
|
|
65
|
+
toolCalls: Array.isArray(record.toolCalls) ? record.toolCalls : [],
|
|
66
|
+
field: record.field === "email" || record.field === "code" || record.field === "name"
|
|
67
|
+
? record.field
|
|
68
|
+
: "message",
|
|
69
|
+
turnsRemaining: typeof record.turnsRemaining === "number" ? record.turnsRemaining : 0,
|
|
70
|
+
// Defaults to `true` on an older server that does not send it: claiming
|
|
71
|
+
// a reply was ungrounded when it may not have been would put a false
|
|
72
|
+
// gap in somebody's knowledge-base report.
|
|
73
|
+
grounded: record.grounded !== false,
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
case "error":
|
|
77
|
+
handlers.onError(typeof record.message === "string"
|
|
78
|
+
? record.message
|
|
79
|
+
: "Sorry - something went wrong. Please try again.");
|
|
80
|
+
return;
|
|
81
|
+
default:
|
|
82
|
+
// An event this version does not know about. Ignored rather than treated
|
|
83
|
+
// as an error, so Connect can add one without breaking a pinned SDK.
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The times a turn looked up, lifted out of its tool calls.
|
|
89
|
+
*
|
|
90
|
+
* **This is what the buttons are drawn from, never the assistant's sentence.** A
|
|
91
|
+
* time the visitor taps is then one the server offered, which is what makes it
|
|
92
|
+
* impossible to book an appointment at a time nobody gave - the property
|
|
93
|
+
* `/visitor/book` depends on and cannot check for itself.
|
|
94
|
+
*
|
|
95
|
+
* `null` when the turn looked nothing up, and also when the lookup *refused* -
|
|
96
|
+
* a tool answering `ok: false` is the assistant being told there is nothing
|
|
97
|
+
* available, and it says so in prose. Rendering an empty slot list under that
|
|
98
|
+
* would be a second, emptier way of saying the same thing.
|
|
99
|
+
*/
|
|
100
|
+
function visitorSlotOfferFrom(toolCalls) {
|
|
101
|
+
// The last one wins: a turn that looked twice - a wider window after an empty
|
|
102
|
+
// first answer - should offer what it found, not what it did not.
|
|
103
|
+
for (let index = toolCalls.length - 1; index >= 0; index -= 1) {
|
|
104
|
+
const call = toolCalls[index];
|
|
105
|
+
if (!call || call.name !== "find_slots")
|
|
106
|
+
continue;
|
|
107
|
+
const result = call.result;
|
|
108
|
+
if (!result || result.ok !== true || !Array.isArray(result.slots))
|
|
109
|
+
continue;
|
|
110
|
+
const slots = result.slots
|
|
111
|
+
.map((slot) => slot)
|
|
112
|
+
.filter((slot) => typeof slot.startAt === "string" && typeof slot.label === "string")
|
|
113
|
+
.map((slot) => ({ startAt: slot.startAt, label: slot.label }));
|
|
114
|
+
if (slots.length === 0)
|
|
115
|
+
return null;
|
|
116
|
+
return {
|
|
117
|
+
eventType: typeof result.eventType === "string" ? result.eventType : "Call",
|
|
118
|
+
durationMinutes: typeof result.durationMinutes === "number" ? result.durationMinutes : 30,
|
|
119
|
+
timezone: typeof result.timezone === "string" ? result.timezone : "UTC",
|
|
120
|
+
slots,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
package/dist/cjs/client/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.
|
|
5
|
-
exports.
|
|
6
|
-
exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useBookableEventTypes = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.isFreePlan = exports.ACTION_LABEL = exports.discountLabel = exports.annualSaving = exports.checkoutSubscriptionId = void 0;
|
|
4
|
+
exports.useConnectPush = exports.useActivePath = exports.ConnectSideNav = exports.ConnectSidebarNav = exports.RAIL_EXPANDED = exports.RAIL_COLLAPSED = exports.sidebarRowClass = exports.useConnectShell = exports.ConnectShellProvider = exports.ConnectSidebarRow = exports.ConnectSidebarFooter = exports.ConnectSidebarBody = exports.ConnectSidebarHeader = exports.ConnectSidebar = exports.AppShell = exports.ConnectAccountSessions = exports.ConnectSignOutButton = exports.ConnectHomeButton = exports.ConnectProfileButton = exports.ConnectInvoicesButton = exports.ConnectManageSubscriptionButton = exports.ConnectUserMenu = exports.ConnectProfileModal = exports.ConnectUserButton = exports.ConnectOneTap = exports.ConnectRegisterButton = exports.ConnectLoginButton = exports.ConnectLogo = exports.ConnectMark = exports.ConnectGrievanceNotice = exports.isConnectError = exports.ConnectError = exports.normalizePlanFeatures = exports.blockingBlockers = exports.CONNECT_APP_URL = exports.useConnectResource = exports.useConnectIntegrations = exports.useConnectSessions = exports.useConnectAddonUsage = exports.useConnectCompanyProfile = exports.useConnectApps = exports.useConnectPlans = exports.useConnectEntitlements = exports.useConnectPlatformAccess = exports.useConnectAudience = exports.useConnectWorkspaceType = exports.useConnectUser = exports.useConnectOptional = exports.useConnect = exports.ConnectProvider = void 0;
|
|
5
|
+
exports.ConnectFilterTabs = exports.ConnectCursorPagination = exports.ConnectConfirmDialog = exports.ConnectLinkButton = exports.ConnectPlansPage = exports.ConnectUsageMeter = exports.ConnectPlatformGate = exports.ConnectWorkspaceTypeGate = exports.useConnectFeature = exports.ConnectFeatureGate = exports.useConnectAccessGate = exports.ConnectAccessGate = exports.ConnectReadinessGate = exports.ConnectReadinessBanner = exports.resolveSubscriptionState = exports.ConnectSeatUsage = exports.ConnectTrialBanner = exports.ConnectSubscriptionCard = exports.ConnectNoSubscriptionCard = exports.ConnectSubscriptionGate = exports.ConnectSubscribeDialog = exports.ConnectPlans = exports.ConnectIntegrationsModal = exports.AI_TONE_DOT = exports.AI_TONE_CHIP = exports.AI_EMPTY_VALUE = exports.aiStatusTone = exports.isNumericAiFormat = exports.formatAiValue = exports.AiSources = exports.AiSteps = exports.AiDetails = exports.AiTimeline = exports.AiMetrics = exports.AiChart = exports.AiTable = exports.ConnectAskAi = exports.ConnectAppsGrid = exports.visitorSlotOfferFrom = exports.readVisitorStream = exports.ConnectVisitorChat = exports.isLinkableConnectScreen = exports.matchConnectScreen = exports.filterNavForAudience = exports.gateExemptPaths = exports.isNavItemActive = exports.resolveActivePath = exports.normalizePath = exports.flattenNav = exports.normalizeNav = void 0;
|
|
6
|
+
exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useBookableEventTypes = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.isFreePlan = exports.ACTION_LABEL = exports.discountLabel = exports.annualSaving = exports.checkoutSubscriptionId = exports.resolveAction = exports.monthlyOf = exports.formatPrice = exports.buildCheckoutUrl = exports.useConnectCheckout = void 0;
|
|
7
7
|
/**
|
|
8
8
|
* Browser half of `@cosmoops/connect-sdk` - React components and hooks.
|
|
9
9
|
*
|
|
@@ -19,6 +19,9 @@ exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.use
|
|
|
19
19
|
var provider_js_1 = require("./provider.js");
|
|
20
20
|
Object.defineProperty(exports, "ConnectProvider", { enumerable: true, get: function () { return provider_js_1.ConnectProvider; } });
|
|
21
21
|
Object.defineProperty(exports, "useConnect", { enumerable: true, get: function () { return provider_js_1.useConnect; } });
|
|
22
|
+
// For a component that must render on a page with no provider - see its own
|
|
23
|
+
// note. `<ConnectVisitorChat>` is the case it exists for.
|
|
24
|
+
Object.defineProperty(exports, "useConnectOptional", { enumerable: true, get: function () { return provider_js_1.useConnectOptional; } });
|
|
22
25
|
Object.defineProperty(exports, "useConnectUser", { enumerable: true, get: function () { return provider_js_1.useConnectUser; } });
|
|
23
26
|
Object.defineProperty(exports, "useConnectWorkspaceType", { enumerable: true, get: function () { return provider_js_1.useConnectWorkspaceType; } });
|
|
24
27
|
Object.defineProperty(exports, "useConnectAudience", { enumerable: true, get: function () { return provider_js_1.useConnectAudience; } });
|
|
@@ -104,6 +107,13 @@ Object.defineProperty(exports, "ConnectSidebarNav", { enumerable: true, get: fun
|
|
|
104
107
|
Object.defineProperty(exports, "ConnectSideNav", { enumerable: true, get: function () { return side_nav_js_1.ConnectSideNav; } });
|
|
105
108
|
var use_active_path_js_1 = require("./use-active-path.js");
|
|
106
109
|
Object.defineProperty(exports, "useActivePath", { enumerable: true, get: function () { return use_active_path_js_1.useActivePath; } });
|
|
110
|
+
/**
|
|
111
|
+
* Web Push. The hook owns the browser half - permission, the worker, the
|
|
112
|
+
* subscription - and Connect owns the keys, the devices and the member's
|
|
113
|
+
* preferences. Sending is server-side, through `connect.push.send`.
|
|
114
|
+
*/
|
|
115
|
+
var use_push_js_1 = require("./use-push.js");
|
|
116
|
+
Object.defineProperty(exports, "useConnectPush", { enumerable: true, get: function () { return use_push_js_1.useConnectPush; } });
|
|
107
117
|
// The nav model is plain data and pure functions - a host generating its nav
|
|
108
118
|
// from its own route table needs the types, and `resolveActivePath` is the
|
|
109
119
|
// same longest-prefix rule the sidebar highlights with, so a breadcrumb or a
|
|
@@ -119,6 +129,16 @@ Object.defineProperty(exports, "filterNavForAudience", { enumerable: true, get:
|
|
|
119
129
|
var ai_screens_js_1 = require("./components/ai-screens.js");
|
|
120
130
|
Object.defineProperty(exports, "matchConnectScreen", { enumerable: true, get: function () { return ai_screens_js_1.matchConnectScreen; } });
|
|
121
131
|
Object.defineProperty(exports, "isLinkableConnectScreen", { enumerable: true, get: function () { return ai_screens_js_1.isLinkableConnectScreen; } });
|
|
132
|
+
/*
|
|
133
|
+
* The public chat bubble. Unlike every other component here it renders for a
|
|
134
|
+
* visitor with **no account**, and works on a page with no `ConnectProvider`
|
|
135
|
+
* at all - which is most marketing pages.
|
|
136
|
+
*/
|
|
137
|
+
var visitor_chat_js_1 = require("./components/visitor-chat.js");
|
|
138
|
+
Object.defineProperty(exports, "ConnectVisitorChat", { enumerable: true, get: function () { return visitor_chat_js_1.ConnectVisitorChat; } });
|
|
139
|
+
var visitor_stream_js_1 = require("./components/visitor-stream.js");
|
|
140
|
+
Object.defineProperty(exports, "readVisitorStream", { enumerable: true, get: function () { return visitor_stream_js_1.readVisitorStream; } });
|
|
141
|
+
Object.defineProperty(exports, "visitorSlotOfferFrom", { enumerable: true, get: function () { return visitor_stream_js_1.visitorSlotOfferFrom; } });
|
|
122
142
|
var apps_grid_js_1 = require("./components/apps-grid.js");
|
|
123
143
|
Object.defineProperty(exports, "ConnectAppsGrid", { enumerable: true, get: function () { return apps_grid_js_1.ConnectAppsGrid; } });
|
|
124
144
|
// `AppShell askAi` mounts this; exported for an app that wants the panel
|
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.CONNECT_APP_URL = void 0;
|
|
5
5
|
exports.ConnectProvider = ConnectProvider;
|
|
6
6
|
exports.useConnect = useConnect;
|
|
7
|
+
exports.useConnectOptional = useConnectOptional;
|
|
7
8
|
exports.useConnectUser = useConnectUser;
|
|
8
9
|
exports.useConnectWorkspaceType = useConnectWorkspaceType;
|
|
9
10
|
exports.useConnectAudience = useConnectAudience;
|
|
@@ -351,6 +352,29 @@ function useConnect() {
|
|
|
351
352
|
}
|
|
352
353
|
return value;
|
|
353
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* The context when there is one, `null` when there is not.
|
|
357
|
+
*
|
|
358
|
+
* **For a component that must work on a page with no provider**, which is a
|
|
359
|
+
* real case rather than defensive coding: `<ConnectVisitorChat>` goes on a
|
|
360
|
+
* public marketing page, and those pages frequently have no `ConnectProvider`
|
|
361
|
+
* anywhere near them - there is no session to hold and nothing on the page that
|
|
362
|
+
* needs one. `useConnect()` throwing is right for every component that reads a
|
|
363
|
+
* session; it is wrong for one whose whole purpose is the visitor who has none.
|
|
364
|
+
*
|
|
365
|
+
* What inheriting the context buys such a component, when it is inside one, is
|
|
366
|
+
* `basePath` and the injected `fetch` - so the same component renders against
|
|
367
|
+
* Storybook's mock bridge and the docs site's canned fetch without either of
|
|
368
|
+
* them having to pass anything.
|
|
369
|
+
*
|
|
370
|
+
* Do not reach for this to make an ordinary component tolerant of a missing
|
|
371
|
+
* provider. A session-reading component that silently renders a signed-out
|
|
372
|
+
* state because somebody forgot to wrap their app is a bug that looks like a
|
|
373
|
+
* product decision.
|
|
374
|
+
*/
|
|
375
|
+
function useConnectOptional() {
|
|
376
|
+
return (0, react_1.useContext)(ConnectContext);
|
|
377
|
+
}
|
|
354
378
|
/** The signed-in user, or `null`. Sugar over `useConnect().session`. */
|
|
355
379
|
function useConnectUser() {
|
|
356
380
|
return useConnect().session?.user ?? null;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.useConnectPush = useConnectPush;
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const provider_js_1 = require("./provider.js");
|
|
7
|
+
/**
|
|
8
|
+
* Read the browser's own permission as external state.
|
|
9
|
+
*
|
|
10
|
+
* Not `useState` in an effect: the permission belongs to the browser, it can
|
|
11
|
+
* change from the address bar while the page is open, and it does not exist
|
|
12
|
+
* during a server render. `useSyncExternalStore` is the primitive for exactly
|
|
13
|
+
* that - a server snapshot of `"checking"` and a live client one.
|
|
14
|
+
*/
|
|
15
|
+
function subscribeToPermission(onChange) {
|
|
16
|
+
let status = null;
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
// Absent on some Safari builds, and throws on others. Neither is fatal:
|
|
19
|
+
// without the subscription the snapshot is still correct on every render, it
|
|
20
|
+
// simply will not update by itself.
|
|
21
|
+
navigator.permissions
|
|
22
|
+
?.query({ name: "notifications" })
|
|
23
|
+
.then((result) => {
|
|
24
|
+
if (cancelled)
|
|
25
|
+
return;
|
|
26
|
+
status = result;
|
|
27
|
+
result.addEventListener("change", onChange);
|
|
28
|
+
})
|
|
29
|
+
.catch(() => { });
|
|
30
|
+
return () => {
|
|
31
|
+
cancelled = true;
|
|
32
|
+
status?.removeEventListener("change", onChange);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function permissionSnapshot() {
|
|
36
|
+
if (typeof navigator === "undefined" ||
|
|
37
|
+
!("serviceWorker" in navigator) ||
|
|
38
|
+
typeof window === "undefined" ||
|
|
39
|
+
!("PushManager" in window) ||
|
|
40
|
+
!("Notification" in window)) {
|
|
41
|
+
return "unsupported";
|
|
42
|
+
}
|
|
43
|
+
if (Notification.permission === "denied")
|
|
44
|
+
return "denied";
|
|
45
|
+
return Notification.permission === "granted" ? "granted" : "prompt";
|
|
46
|
+
}
|
|
47
|
+
const serverSnapshot = () => "checking";
|
|
48
|
+
/**
|
|
49
|
+
* Register this browser for the platform's notifications.
|
|
50
|
+
*
|
|
51
|
+
* The subscription is **Connect's, not your application's**: a member who
|
|
52
|
+
* turns notifications on in one app has turned them on for the platform, and
|
|
53
|
+
* the same device receives every other app's notifications and Connect's own.
|
|
54
|
+
* That is the point of centralising it - one browser prompt instead of one per
|
|
55
|
+
* app, and one place to turn everything off.
|
|
56
|
+
*
|
|
57
|
+
* Your application needs a service worker at its own origin that handles the
|
|
58
|
+
* `push` event; the SDK does not install one, because a worker is a
|
|
59
|
+
* site-wide concern with caching implications only the host can decide.
|
|
60
|
+
*
|
|
61
|
+
* @param serviceWorkerUrl Where your worker lives. Root-scoped by convention.
|
|
62
|
+
*/
|
|
63
|
+
function useConnectPush(serviceWorkerUrl = "/sw.js") {
|
|
64
|
+
const { fetchResource, mutateResource } = (0, provider_js_1.useConnect)();
|
|
65
|
+
const permission = (0, react_1.useSyncExternalStore)(subscribeToPermission, permissionSnapshot, serverSnapshot);
|
|
66
|
+
const [isEnabling, setIsEnabling] = (0, react_1.useState)(false);
|
|
67
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
68
|
+
const [unconfigured, setUnconfigured] = (0, react_1.useState)(false);
|
|
69
|
+
const enable = (0, react_1.useCallback)(async () => {
|
|
70
|
+
setError(null);
|
|
71
|
+
setIsEnabling(true);
|
|
72
|
+
try {
|
|
73
|
+
/*
|
|
74
|
+
* The key first, and the order matters: if the platform cannot send,
|
|
75
|
+
* asking for permission spends the member's one prompt on a feature
|
|
76
|
+
* that will never deliver - and a denial cannot be re-asked.
|
|
77
|
+
*/
|
|
78
|
+
const key = await fetchResource("/push/key");
|
|
79
|
+
if (!key.configured || !key.publicKey) {
|
|
80
|
+
setUnconfigured(true);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if ((await Notification.requestPermission()) !== "granted")
|
|
84
|
+
return null;
|
|
85
|
+
const registration = await navigator.serviceWorker.register(serviceWorkerUrl);
|
|
86
|
+
// `ready` rather than the registration itself: one that is still
|
|
87
|
+
// installing has no `pushManager`, and subscribing against it throws in
|
|
88
|
+
// a way that reads as a permission problem.
|
|
89
|
+
await navigator.serviceWorker.ready;
|
|
90
|
+
const subscription = await registration.pushManager.subscribe({
|
|
91
|
+
// Required, and it may not be false - a silent push is reserved for
|
|
92
|
+
// sites with a far higher engagement bar, and asking for one here has
|
|
93
|
+
// the browser refuse the subscription outright.
|
|
94
|
+
userVisibleOnly: true,
|
|
95
|
+
applicationServerKey: base64UrlToBytes(key.publicKey),
|
|
96
|
+
});
|
|
97
|
+
const json = subscription.toJSON();
|
|
98
|
+
const registered = await mutateResource("/push/subscribe", {
|
|
99
|
+
endpoint: json.endpoint,
|
|
100
|
+
keys: { p256dh: json.keys?.p256dh, auth: json.keys?.auth },
|
|
101
|
+
});
|
|
102
|
+
return registered.id;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
setError("This browser would not register for notifications. Reload the page and try again.");
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
setIsEnabling(false);
|
|
110
|
+
}
|
|
111
|
+
}, [fetchResource, mutateResource, serviceWorkerUrl]);
|
|
112
|
+
const disable = (0, react_1.useCallback)(async (deviceId) => {
|
|
113
|
+
await mutateResource("/push/unsubscribe", { id: deviceId });
|
|
114
|
+
}, [mutateResource]);
|
|
115
|
+
return {
|
|
116
|
+
status: unconfigured && permission !== "denied" ? "unconfigured" : permission,
|
|
117
|
+
isEnabling,
|
|
118
|
+
error,
|
|
119
|
+
enable,
|
|
120
|
+
disable,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The VAPID public key as the bytes `pushManager.subscribe()` wants.
|
|
125
|
+
*
|
|
126
|
+
* It travels base64url and the API takes a `BufferSource`. Spelled out rather
|
|
127
|
+
* than inlined because getting it wrong produces an `InvalidAccessError` that
|
|
128
|
+
* reads as a permissions fault rather than an encoding one - and the explicit
|
|
129
|
+
* `ArrayBuffer` matters too: `Uint8Array.from` widens to `ArrayBufferLike`,
|
|
130
|
+
* which `applicationServerKey` will not accept.
|
|
131
|
+
*/
|
|
132
|
+
function base64UrlToBytes(base64) {
|
|
133
|
+
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
|
|
134
|
+
const raw = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
|
|
135
|
+
const bytes = new Uint8Array(new ArrayBuffer(raw.length));
|
|
136
|
+
for (let index = 0; index < raw.length; index += 1)
|
|
137
|
+
bytes[index] = raw.charCodeAt(index);
|
|
138
|
+
return bytes;
|
|
139
|
+
}
|
|
@@ -23,6 +23,8 @@ const analytics_js_1 = require("./resources/analytics.js");
|
|
|
23
23
|
const otp_js_1 = require("./resources/otp.js");
|
|
24
24
|
const sessions_js_1 = require("./resources/sessions.js");
|
|
25
25
|
const platform_js_1 = require("./resources/platform.js");
|
|
26
|
+
const visitor_js_1 = require("./resources/visitor.js");
|
|
27
|
+
const push_js_1 = require("./resources/push.js");
|
|
26
28
|
exports.DEFAULT_BASE_URL = "https://connect.cosmoops.com";
|
|
27
29
|
const APPLICATION_API_PREFIX = "/api/v1/application";
|
|
28
30
|
/**
|
|
@@ -114,8 +116,18 @@ class ConnectClient {
|
|
|
114
116
|
integrations;
|
|
115
117
|
/** Which workspaces the signed-in user belongs to, switching between them, and the team - see `WorkspacesResource`. */
|
|
116
118
|
workspaces;
|
|
119
|
+
/**
|
|
120
|
+
* The public chat bubble on your own site - see `VisitorResource`. The one
|
|
121
|
+
* resource here whose caller is not one of your users.
|
|
122
|
+
*/
|
|
123
|
+
visitor;
|
|
117
124
|
/** The platform's own legal identity and DPDP grievance channel - see `PlatformResource`. */
|
|
118
125
|
platform;
|
|
126
|
+
/**
|
|
127
|
+
* Web Push. Connect holds the VAPID keys, the devices and the member's own
|
|
128
|
+
* preferences; you supply a title, a body and a URL. See `PushResource`.
|
|
129
|
+
*/
|
|
130
|
+
push;
|
|
119
131
|
/**
|
|
120
132
|
* Relays one `/data/*` or `/kv/*` request verbatim - `createConnectHandler`'s
|
|
121
133
|
* store route and nothing else. See `storePassthrough`.
|
|
@@ -159,10 +171,12 @@ class ConnectClient {
|
|
|
159
171
|
this.sessions = new sessions_js_1.SessionsResource(transport, options.clientId ?? null);
|
|
160
172
|
this.integrations = new integrations_js_1.IntegrationsResource(transport);
|
|
161
173
|
this.workspaces = new workspaces_js_1.WorkspacesResource(transport);
|
|
174
|
+
this.visitor = new visitor_js_1.VisitorResource(transport);
|
|
162
175
|
this.platform = new platform_js_1.PlatformResource(transport);
|
|
176
|
+
this.push = new push_js_1.PushResource(transport);
|
|
163
177
|
this.storeRelay = (spec) => (0, data_js_1.storePassthrough)(transport, spec);
|
|
164
178
|
}
|
|
165
179
|
}
|
|
166
180
|
exports.ConnectClient = ConnectClient;
|
|
167
181
|
/** Kept in step with `package.json` by `sdk.test.ts`. */
|
|
168
|
-
exports.SDK_VERSION = "0.
|
|
182
|
+
exports.SDK_VERSION = "0.32.0";
|
|
@@ -99,6 +99,24 @@ function redirect(location, cookies = []) {
|
|
|
99
99
|
* workspace it is not signed into, and a client-supplied `workspaceId`
|
|
100
100
|
* is exactly that hole.
|
|
101
101
|
*/
|
|
102
|
+
/**
|
|
103
|
+
* The visitor's own address, for Connect's per-visitor limiter.
|
|
104
|
+
*
|
|
105
|
+
* **Left-most `x-forwarded-for` entry**, which is the same precedence Connect's
|
|
106
|
+
* own `/api/book` and `/api/auth/session` use, and the same caveat applies: the
|
|
107
|
+
* left-most entry is the client as the first proxy saw it, and a client can
|
|
108
|
+
* prepend to that header. That makes it unsuitable for authorization and fine
|
|
109
|
+
* for this - it partitions your application's own budget between visitors, and
|
|
110
|
+
* the ceilings that actually cap spending are per key and per application.
|
|
111
|
+
*
|
|
112
|
+
* `null` where nothing forwarded one - a local dev server, or a platform that
|
|
113
|
+
* does not set it. Connect then counts every visitor in one shared bucket,
|
|
114
|
+
* which is a stricter limit rather than an exemption.
|
|
115
|
+
*/
|
|
116
|
+
function visitorIpOf(request) {
|
|
117
|
+
const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
118
|
+
return forwarded || request.headers.get("x-real-ip") || null;
|
|
119
|
+
}
|
|
102
120
|
function createConnectHandler(options) {
|
|
103
121
|
const basePath = (options.basePath ?? "/api/connect").replace(/\/+$/, "");
|
|
104
122
|
const { client, session } = options;
|
|
@@ -330,6 +348,130 @@ function createConnectHandler(options) {
|
|
|
330
348
|
return json({ readiness, subscription });
|
|
331
349
|
},
|
|
332
350
|
"GET /plans": async () => json(await client.billing.listPlans()),
|
|
351
|
+
/* ---------------------------------------------------------------------- */
|
|
352
|
+
/* The visitor assistant - the only routes here with no session at all */
|
|
353
|
+
/* ---------------------------------------------------------------------- */
|
|
354
|
+
/**
|
|
355
|
+
* `POST /visitor/session`, `/visitor/chat`, `/visitor/book`.
|
|
356
|
+
*
|
|
357
|
+
* Three routes that take **no session**, which makes them the exception to
|
|
358
|
+
* the rule every other route here follows. That is not a hole: the caller
|
|
359
|
+
* is a stranger on a public page, and there is no account for a session to
|
|
360
|
+
* identify. The question "whose data is this" has a different answer here -
|
|
361
|
+
* it is the *application's*, and the application is the one thing this
|
|
362
|
+
* bridge does hold a credential for.
|
|
363
|
+
*
|
|
364
|
+
* So the scoping that `requireWorkspace` provides elsewhere is provided
|
|
365
|
+
* here by Connect's own configuration: the workspace a call books into and
|
|
366
|
+
* the knowledge it answers from come from the application record, and no
|
|
367
|
+
* field in any of these bodies names a tenant. A browser cannot widen that
|
|
368
|
+
* by asking, because there is nothing to ask with.
|
|
369
|
+
*
|
|
370
|
+
* **`GET /company-profile` is the precedent and the limit of it.** That one
|
|
371
|
+
* is unauthenticated because the answer is identical for every caller.
|
|
372
|
+
* These are unauthenticated because the *caller* has no identity - but the
|
|
373
|
+
* answer is still per application, and spending is still per application.
|
|
374
|
+
* Which is why they are the only routes here that pass the visitor's own
|
|
375
|
+
* address along: the limiters on the far side count a visitor, and this
|
|
376
|
+
* bridge is the only thing that knows which one.
|
|
377
|
+
*/
|
|
378
|
+
"POST /visitor/session": async (request) => {
|
|
379
|
+
const body = (await request.json().catch(() => null));
|
|
380
|
+
/*
|
|
381
|
+
* **The workspace is the browser's claim here, and that is the one thing
|
|
382
|
+
* this bridge must not simply relay.** Every other route on this bridge
|
|
383
|
+
* resolves the tenant from the caller's session; a visitor has none, so
|
|
384
|
+
* the widget names the customer whose site it is on - and a page can name
|
|
385
|
+
* any of them.
|
|
386
|
+
*
|
|
387
|
+
* Connect checks the workspace against your subscriptions, so the worst
|
|
388
|
+
* case is bounded to *your own* customers rather than the platform's. That
|
|
389
|
+
* is still one customer's budget spent from another's page, so an
|
|
390
|
+
* application serving several customers should resolve the workspace from
|
|
391
|
+
* its own widget record - by the embed token in the snippet, say - rather
|
|
392
|
+
* than trusting this field. The SDK cannot do that for you: it does not
|
|
393
|
+
* know what your widgets are keyed by.
|
|
394
|
+
*/
|
|
395
|
+
if (typeof body?.workspaceId !== "string") {
|
|
396
|
+
throw new errors_js_1.ConnectError({
|
|
397
|
+
status: 400,
|
|
398
|
+
code: "validation_error",
|
|
399
|
+
message: "workspaceId is required",
|
|
400
|
+
endpoint: "POST /visitor/session",
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
return json(await client.visitor.startSession({
|
|
404
|
+
workspaceId: body.workspaceId,
|
|
405
|
+
page: typeof body.page === "string" ? body.page : null,
|
|
406
|
+
resume: typeof body.resume === "string" ? body.resume : null,
|
|
407
|
+
}, { visitorIp: visitorIpOf(request) }));
|
|
408
|
+
},
|
|
409
|
+
"POST /visitor/chat": async (request) => {
|
|
410
|
+
const body = (await request.json().catch(() => null));
|
|
411
|
+
if (typeof body?.sessionId !== "string" ||
|
|
412
|
+
typeof body?.workspaceId !== "string" ||
|
|
413
|
+
typeof body?.message !== "string") {
|
|
414
|
+
throw new errors_js_1.ConnectError({
|
|
415
|
+
status: 400,
|
|
416
|
+
code: "validation_error",
|
|
417
|
+
message: "sessionId, workspaceId and message are required",
|
|
418
|
+
endpoint: "POST /visitor/chat",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
const upstream = await client.visitor.chat({
|
|
422
|
+
sessionId: body.sessionId,
|
|
423
|
+
workspaceId: body.workspaceId,
|
|
424
|
+
message: body.message,
|
|
425
|
+
...(typeof body.assistantName === "string"
|
|
426
|
+
? { assistantName: body.assistantName }
|
|
427
|
+
: {}),
|
|
428
|
+
...(typeof body.bookingEventTypeSlug === "string"
|
|
429
|
+
? { bookingEventTypeSlug: body.bookingEventTypeSlug }
|
|
430
|
+
: {}),
|
|
431
|
+
}, { visitorIp: visitorIpOf(request) });
|
|
432
|
+
/*
|
|
433
|
+
* Piped, never buffered - awaiting the body would collect the whole reply
|
|
434
|
+
* server-side and deliver it in one lump, which is the only thing
|
|
435
|
+
* streaming was for. The browser hanging up propagates down this pipe and
|
|
436
|
+
* stops generation upstream, which on this surface also stops the
|
|
437
|
+
* spending.
|
|
438
|
+
*/
|
|
439
|
+
return new Response(upstream.body, {
|
|
440
|
+
status: upstream.status,
|
|
441
|
+
headers: {
|
|
442
|
+
"content-type": upstream.headers.get("content-type") ?? "text/event-stream",
|
|
443
|
+
"cache-control": "no-store",
|
|
444
|
+
// Without this a proxy in front of your app may buffer the whole
|
|
445
|
+
// stream to inspect it, which undoes the streaming end to end.
|
|
446
|
+
"x-accel-buffering": "no",
|
|
447
|
+
"x-content-type-options": "nosniff",
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
},
|
|
451
|
+
"POST /visitor/book": async (request) => {
|
|
452
|
+
const body = (await request.json().catch(() => null));
|
|
453
|
+
if (typeof body?.sessionId !== "string" ||
|
|
454
|
+
typeof body?.workspaceId !== "string" ||
|
|
455
|
+
typeof body?.eventTypeSlug !== "string" ||
|
|
456
|
+
typeof body?.startAt !== "string") {
|
|
457
|
+
throw new errors_js_1.ConnectError({
|
|
458
|
+
status: 400,
|
|
459
|
+
code: "validation_error",
|
|
460
|
+
message: "sessionId, workspaceId, eventTypeSlug and startAt are required",
|
|
461
|
+
endpoint: "POST /visitor/book",
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return json(await client.visitor.book({
|
|
465
|
+
sessionId: body.sessionId,
|
|
466
|
+
workspaceId: body.workspaceId,
|
|
467
|
+
eventTypeSlug: body.eventTypeSlug,
|
|
468
|
+
startAt: body.startAt,
|
|
469
|
+
name: typeof body.name === "string" ? body.name : "",
|
|
470
|
+
phone: typeof body.phone === "string" ? body.phone : "",
|
|
471
|
+
email: typeof body.email === "string" ? body.email : null,
|
|
472
|
+
notes: typeof body.notes === "string" ? body.notes : null,
|
|
473
|
+
}, { visitorIp: visitorIpOf(request) }));
|
|
474
|
+
},
|
|
333
475
|
/**
|
|
334
476
|
* The platform's legal identity and grievance channel.
|
|
335
477
|
*
|
|
@@ -516,6 +658,60 @@ function createConnectHandler(options) {
|
|
|
516
658
|
* switching is simply absent for them rather than half-working - the same
|
|
517
659
|
* treatment `/signin` and `/callback` already get.
|
|
518
660
|
*/
|
|
661
|
+
/*
|
|
662
|
+
* Push notifications - the browser half, and deliberately only that half.
|
|
663
|
+
*
|
|
664
|
+
* **There is no `POST /push` here**, and its absence is the design: a
|
|
665
|
+
* notification anybody can trigger from a devtools console is one an
|
|
666
|
+
* attacker can trigger from a compromised page, wearing the styling of an
|
|
667
|
+
* app the person trusts. Sending is a server-to-server act with your API
|
|
668
|
+
* key; Connect's own route refuses the session-token path outright.
|
|
669
|
+
*
|
|
670
|
+
* What a browser legitimately does is register itself, because
|
|
671
|
+
* `pushManager.subscribe()` produces a subscription no server can make on
|
|
672
|
+
* its behalf. All three routes resolve the member from their session, so
|
|
673
|
+
* there is no `userId` anywhere on this surface - a page cannot register a
|
|
674
|
+
* device against somebody else's account, nor list theirs.
|
|
675
|
+
*/
|
|
676
|
+
"GET /push/key": async (request) => json(await client.push.key({ asUser: await requireToken(request) })),
|
|
677
|
+
"POST /push/subscribe": async (request) => {
|
|
678
|
+
const token = await requireToken(request);
|
|
679
|
+
const body = (await request.json().catch(() => null));
|
|
680
|
+
// Shape-checked here rather than relayed blindly, so a malformed
|
|
681
|
+
// subscription fails with a sentence naming what is missing instead of
|
|
682
|
+
// a Zod message from a server the page cannot see.
|
|
683
|
+
if (typeof body?.endpoint !== "string" ||
|
|
684
|
+
typeof body.keys?.p256dh !== "string" ||
|
|
685
|
+
typeof body.keys?.auth !== "string") {
|
|
686
|
+
throw new errors_js_1.ConnectError({
|
|
687
|
+
status: 400,
|
|
688
|
+
code: "validation_error",
|
|
689
|
+
message: "endpoint and keys.p256dh/keys.auth are required",
|
|
690
|
+
endpoint: "POST /push/subscribe",
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
return json(await client.push.registerDevice({
|
|
694
|
+
endpoint: body.endpoint,
|
|
695
|
+
keys: { p256dh: body.keys.p256dh, auth: body.keys.auth },
|
|
696
|
+
// Taken from the request rather than the body: a page can put
|
|
697
|
+
// anything in a field, and this one is shown back on a device
|
|
698
|
+
// list where a spoofed entry would be the point of spoofing it.
|
|
699
|
+
userAgent: (request.headers.get("user-agent") ?? "").slice(0, 512),
|
|
700
|
+
}, { asUser: token }));
|
|
701
|
+
},
|
|
702
|
+
"POST /push/unsubscribe": async (request) => {
|
|
703
|
+
const token = await requireToken(request);
|
|
704
|
+
const body = (await request.json().catch(() => null));
|
|
705
|
+
if (typeof body?.id !== "string") {
|
|
706
|
+
throw new errors_js_1.ConnectError({
|
|
707
|
+
status: 400,
|
|
708
|
+
code: "validation_error",
|
|
709
|
+
message: "id is required",
|
|
710
|
+
endpoint: "POST /push/unsubscribe",
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
return json(await client.push.revokeDevice(body.id, { asUser: token }));
|
|
714
|
+
},
|
|
519
715
|
"GET /workspaces": async (request) => json({ workspaces: await client.workspaces.list({ asUser: await requireToken(request) }) }),
|
|
520
716
|
"GET /workspaces/members": async (request) => json({ members: await client.workspaces.members({ asUser: await requireToken(request) }) }),
|
|
521
717
|
"POST /workspaces/switch": async (request) => {
|