squarefi-livechat-widget
v1.0.7
Published
Host-side widget for the Squarefi CRM support chat: launcher, sandboxed frame, Clerk template token hand-off.
Readme
squarefi-livechat-widget
Host-side widget for the Squarefi CRM support chat. It adds a launcher button and a sandboxed iframe from the CRM to your app, hands the frame a short-lived JWT, and gets out of the way when the chat is not available, so your app can show its own support entry point.
The CRM serves the chat itself (/livechat/frame, /api/livechat/*). This package replaces the CRM's
/livechat/v1/loader.js script tag: no CRM-hosted script runs in your origin, and the code that does run is pinned by
the version in your package.json.
Install
npm install --save-exact squarefi-livechat-widgetPin the exact version. An update is a reviewed PR in your app, not a deploy on someone else's side.
React
'use client';
import { useAuth } from '@clerk/nextjs';
import { toCrmOrigin } from 'squarefi-livechat-widget';
import { useLivechat } from 'squarefi-livechat-widget/react';
const crmOrigin = toCrmOrigin(process.env.LIVECHAT_CRM_ORIGIN);
export const SupportChat = ({ origin }: { origin: string }) => {
const { getToken, isSignedIn } = useAuth();
const { fallbackReason } = useLivechat({
enabled: !!isSignedIn,
crmOrigin: origin,
brand: 'mosta',
locale: 'en',
getToken: async () => {
const token = await getToken({ template: 'mosta-crm-livechat', skipCache: true });
if (!token) throw new Error('signed out');
return token;
},
appearance: { zIndex: 40, launcherBackground: 'var(--accent)', launcherColor: 'var(--accent-foreground)' },
});
return fallbackReason ? <YourOldSupportWidget /> : null;
};useLivechat boots once while enabled is true and shuts the chat down when it turns false or the component unmounts.
getToken, onFallback and appearance are read through refs, so new function identities on every render do not
reboot the chat; a reboot would close a chat the user has open. A change of crmOrigin, brand or locale reboots.
theme (light | dark, e.g. next-themes resolvedTheme) is passed to the frame; when it changes the hook sends it to
the running chat without a reboot. Without React, call setLivechatTheme(theme). Anything but dark is light.
Without React
import { bootLivechat, isLivechatActive, showLivechat, shutdownLivechat } from 'squarefi-livechat-widget';
bootLivechat({ crmOrigin, brand: 'mosta', locale: 'en', getToken, onFallback: (reason) => showOldSupport(reason) });
// "Contact us" button:
if (isLivechatActive()) showLivechat();
// Sign-out:
shutdownLivechat();API: bootLivechat(options) (returns a dispose function for that session), setLivechatTheme(theme), showLivechat(),
hideLivechat(),
shutdownLivechat(), isLivechatActive(), toCrmOrigin(value) (an https origin, plain http only for localhost, or
null), LivechatFallbackReason. crmOrigin goes through toCrmOrigin inside bootLivechat; a value it rejects
falls back with unavailable before any request.
The token
getToken must return the JWT template token, never the plain session token:
getToken({ template: '<livechat template>', skipCache: true })from the browser hook (useAuth());- the template carries
aud,brand,email,nameand notenant_id, so your own APIs refuse it if it leaks; - a server-side
auth().getToken()has noazp, and the CRM answers401 bad_azp; - without
skipCache: truethe auth provider returns its cached token and the refresh loops.
The widget keeps the token in memory, sends it only to the frame (postMessage with the CRM origin as target) and the
config probe, and never logs it. It refreshes the token 60 s before exp, at most once per 5 s when the frame keeps
asking after 401s, and retries every 30 s when the auth provider fails. More than six auth_required in a minute means
the frame is looping: the widget falls back with unavailable.
Fallback
The launcher appears only after GET <crm>/api/livechat/config answers 200 with rollout.enabled === true. Otherwise
onFallback(reason) is called once and nothing is left on the page:
| reason | when |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| token | getToken rejected, returned nothing, or did not answer within 10 s |
| timeout | the config probe did not answer within 3 s |
| unavailable | network error, non-200 other than 404/503, three 401s on fresh tokens, more than six auth_required a minute, no frame ready in 15 s, a rejected crmOrigin |
| disabled | 404/503 from the config probe, or rollout.enabled !== true |
Isolation
- The frame is
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads": withoutallow-top-navigationit cannot navigate your tab, and withoutallow-popups-to-escape-sandboxa popup it opens inherits the sandbox and cannot navigate your tab either. It is cross-origin, so it cannot read your DOM, cookies or storage. Attachments open in a sandboxed tab: images show, files download; a browser may refuse to render a PDF there. - Only the user opens the panel (launcher,
showLivechat()): anopenfrom the frame is ignored. allow="clipboard-write"only; pasted images reach the frame through the paste event.- Messages from the frame are accepted only from the CRM origin and the frame's own window, and only in the protocol v1 shape.
- What the CRM receives: the template token,
page_url(origin and path, no query or hash),locale,brand.
Your app's side
- The CRM must list your exact origin in
LIVECHAT_ALLOWED_ORIGINS(CORS,frame-ancestorsand the token'sazp). - With a Content-Security-Policy, add the CRM origin to
frame-srcandconnect-src.script-srcis not needed. appearancesetszIndex(default2147483000), the launcher, badge and frame colors,frameBorderColor(a 0.5px ring around the panel, drawn as a shadow so it takes no space; none by default) andframeRadius(desktop panel corners, 0–40 px, default 18). A color is any CSS value built from letters, digits,#%.,-+/, spaces and balanced parentheses (hex, names,rgb(),hsl(),oklch(),color-mix(),var(--token, fallback)); anything else, such as quotes,:,*,;or braces, falls back to the default.- The launcher itself:
launcherSize(40–72 px, default 56; the panel opens 12 px above it),launcherShadow(same characters as a color, up to 240,noneallowed),launcherBackdropBlur(0–40 px, for a translucent glass background),launcherIconandlauncherIconSize(12–32 px, default 26).launcherIconreplaces the chat bubble with a monochrome image used as a mask, so it takeslauncherColor: a same-origin path (/brand/symbol.svg) or an https URL, letters, digits and/_.-only. Anything else keeps the bubble. A cross-origin icon needs CORS, as any CSS mask. Theme-dependent values belong in CSS variables, e.g.{ launcherBackground: 'var(--launcher-bg)', launcherShadow: 'var(--launcher-shadow)', launcherBackdropBlur: 20, launcherSize: 60, launcherIcon: '/brand/symbol.svg', launcherIconSize: 22 }. - A CRM e-mail link to your page ending in
#support-chatopens the chat: the widget opens the panel once the frame is ready (also when the fragment changes on an open page) and removes the fragment, keeping the path and the query. Nothing to do on your side; if the chat falls back, the fragment stays for your old widget.
Protocol
The widget speaks the CRM's postMessage protocol v1 (envelope { v: 1, ns: 'mosta-chat', type, ... }):
- frame → host:
ready,unread,close,resize,auth_required,unavailable(openis ignored); - host → frame:
token,open,close,locale,theme(light|dark, afterreadyand on every change).
The frame URL carries brand, host, locale and theme (first paint).
A new protocol version in the CRM needs a release of this package; until then the widget keeps speaking v1.
Development
npm ci
npm run check-types && npm run format && npm test
npx playwright install chromium && npm run test:e2e # browser tests against a fake CRM on another origin
npm run buildEvery merge into main releases a new patch version: the release bot bumps package.json, moves the CHANGELOG.md
[Unreleased] notes under the new version, tags v<version> and publishes to npm. Write your change notes under
[Unreleased] in the PR; for a minor or major release, bump the version in the PR yourself (the bot adds a patch on top).
The bot's key is a secret of the npm GitHub environment (only main and v* tags may deploy to it) and the npm
trusted publisher is bound to that environment, so write access to the repository does not allow publishing.
