@chattoo/rlb-chattoo-widget
v1.0.3
Published
Embeddable chat widget: one script tag on a site, or an npm package in an app
Maintainers
Readme
@chattoo/rlb-chattoo-widget
An embeddable chat widget. One <script> tag on a site you do not control, or an npm package in
an application you do.
It is the browser half of the web channel of rlb-chat-connector: the visitor types here, the
message travels to the connector, and the tenant's answer comes back over a WebSocket. Everything
the widget knows about the platform is three HTTP-ish endpoints — it has no idea a broker exists.
- Runtime: Lit web components — a custom element, not a framework plugin
- Ships as: a single self-contained IIFE (~40 KB, Lit included) and an ESM package with types
- Isolation: shadow DOM. Host page CSS does not leak in, widget CSS does not leak out
Install
As a package
npm install @chattoo/rlb-chattoo-widgetimport { mount } from '@chattoo/rlb-chattoo-widget';
mount({ siteId: 'acme-site', endpoint: 'https://gateway.example.com' });Lit is a normal dependency and stays external in the ESM build, so an application that already uses Lit keeps one copy of it. Two copies would mean two custom element registries fighting over the same tag names.
As a script tag
For sites where you cannot run a build — a CMS, a landing page, someone else's template:
<script src="https://cdn.example.com/chat-widget.js"
data-site-id="acme-site"
data-endpoint="https://gateway.example.com"
data-language="en"
data-theme="dark"></script>That is the whole integration. The script mounts itself from its own attributes: a site that only
wants a chat bubble should not have to write JavaScript. The file is
node_modules/@chattoo/rlb-chattoo-widget/dist/chat-widget.js, or
@chattoo/rlb-chattoo-widget/standalone if your bundler resolves subpaths.
data-endpoint is optional: without it the widget uses the origin the script was served from,
which is right whenever the gateway also serves the file.
Using it in Angular
The widget is a custom element, so Angular needs to be told not to treat <rlb-chat> as a
component of its own. That is one line, and it is the only Angular-specific step.
1. Allow custom elements in the module (or the standalone component) that renders it:
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@NgModule({
declarations: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA], // without this: "rlb-chat is not a known element"
})
export class AppModule {}For a standalone component, put CUSTOM_ELEMENTS_SCHEMA in its own schemas array instead.
2. Register the element once, at startup. Importing the package defines <rlb-chat> and
installs window.rlbChat:
// main.ts
import '@chattoo/rlb-chattoo-widget';3a. Let it float over the whole application — the usual case. Mount it once and forget it:
import { Component, OnInit } from '@angular/core';
import { mount } from '@chattoo/rlb-chattoo-widget';
@Component({ selector: 'app-root', template: '<router-outlet />' })
export class AppComponent implements OnInit {
ngOnInit(): void {
mount({
siteId: 'acme-site',
endpoint: 'https://gateway.example.com',
language: 'en',
});
}
}3b. Or place it in a template, when you want it inside a panel instead of floating:
<rlb-chat site-id="acme-site"
endpoint="https://gateway.example.com"
language="en"
theme="dark"
inline></rlb-chat>Attribute names are dash-cased (site-id, auto-open, no-attachments); property names on the
element are camelCase. Angular's [property] binding writes the property directly, which is what
you want for anything that is not a string:
<rlb-chat [siteId]="siteId" [endpoint]="endpoint" [strings]="customStrings"></rlb-chat>strings is an object and must be bound as a property: an attribute would stringify it.
Two things that bite in Angular specifically
Server-side rendering. The package touches window and document when it loads, so importing
it at module scope breaks an SSR build. Import it inside ngOnInit, or guard it:
if (typeof window !== 'undefined') {
const { mount } = await import('@chattoo/rlb-chattoo-widget');
mount({ siteId, endpoint });
}Zone.js and events. The widget's events cross the shadow boundary (composed: true), so
Angular's (rlb-send) binding catches them normally. What Angular does not see is state changing
inside the widget: it is a web component, not a signal. If you need to know what the visitor sent,
listen for the event rather than reading properties.
Configuration
Everything below is both an attribute and a property. mount() takes the same set as an options
object, plus target and inline.
| attribute | property | default | |
|---|---|---|---|
| site-id | siteId | — | required. Identifies the channel: it is the externalRef of the web channel on the tenant |
| endpoint | endpoint | script origin | where the gateway lives |
| language | language | browser language | en and it ship in the bundle; anything else falls back to en unless you pass strings |
| theme | theme | follows the OS | light or dark |
| position | position | right | which corner the bubble sits in |
| inline | inline | false | render in place instead of floating, for a panel or a page section |
| auto-open | autoOpen | false | open the panel on load instead of waiting for a click |
| no-attachments | noAttachments | false | hide the attachment button |
| captcha-mode | captchaMode | invisible | passed through to the captcha bundle |
| captcha-script | captchaScript | derived from endpoint | override where the captcha bundle is fetched from |
| — | strings | — | partial overrides merged over the bundled strings. Property only |
data-strings on the script tag carries the same thing as JSON. Malformed JSON is ignored rather
than fatal: a typo in an attribute must not cost the site its chat.
Events
Both bubble and cross the shadow boundary, so an ordinary listener on an ancestor catches them.
| event | detail | |
|---|---|---|
| rlb-send | ChatContent | the visitor sent something |
| rlb-choice | ChoiceOption | the visitor tapped a button or a list row |
document.addEventListener('rlb-send', (e) => analytics.track('chat_message', e.detail));Theming
Set CSS custom properties on the element, or on :root for the floating case. They are read
through the shadow boundary, which is the one thing that does cross it by design:
rlb-chat {
--rlb-accent: #7c3aed;
--rlb-accent-contrast: #ffffff;
--rlb-radius: 8px;
--rlb-font: "Inter", system-ui, sans-serif;
}The full set: --rlb-accent, --rlb-accent-contrast, --rlb-surface, --rlb-surface-muted,
--rlb-text, --rlb-text-muted, --rlb-border, --rlb-bubble-agent, --rlb-bubble-visitor,
--rlb-bubble-visitor-text, --rlb-radius, --rlb-shadow, --rlb-font, --rlb-z.
Dark mode overrides the surface and text tokens and leaves the accent alone, so a brand colour set once works in both.
What the widget expects on the other side
Three endpoints under endpoint, exposed in production by rlb-gateway:
| | |
|---|---|
| POST /chat-connector/web/session | opens a session, returns a sessionToken and a sessionId |
| POST /chat-connector/web/messages | sends one message |
| WS /ws | replies, typing indicators and delivery states come back here |
The socket carries the session token as a WebSocket subprotocol rather than a query parameter,
so it never lands in a proxy access log. It then expects
{action: 'subscribe', topic: 'chatWeb', select: {sessionId}}, and a malformed frame is dropped
without an answer.
mock/server.mjs implements all three plus a fake captcha, so the widget can be exercised end to
end with no gateway, no broker and no database:
npm run dev:all # mock server + vite, then open the printed URLWhat it does, and what it refuses to do
It renders text, images, video, audio, documents, buttons and list pickers, shows typing and delivery states, and remembers the session across a page reload.
It does not persist history: reopening after the session expires starts an empty conversation. The transcript belongs to the platform, not to a script on someone else's page.
The contract in src/contract.ts is a subset of the connector's: only the content types the
web channel can actually render. It is duplicated on purpose rather than imported — this package
must not depend on the connector to build.
A known limit: every visitor in one bucket
The captcha bundle does not send a clientId, so upstream every visitor lands in the single
anonymous bucket: one abuser burns the whole site's request budget and raises the proof-of-work
difficulty for every honest browser.
This is not a widget defect and cannot be fixed inside the widget. The fix is either the host site
proxying /captcha* same-origin and injecting the visitor's real IP, or rlb-gateway injecting
the caller's IP on the captcha routes.
/captcha/validate must never be exposed same-origin: it burns the token, and anyone could
consume another visitor's verification.
Working on it
npm install
npm test # vitest — 18 tests
npm run dev:all # mock backend + dev server
npm run build # standalone IIFE, ESM package, and type declarationsnpm run build produces three things in dist/:
| | |
|---|---|
| chat-widget.js | the standalone IIFE, Lit bundled in — the script tag |
| index.js | ESM with Lit left external — the npm package |
| types/ | declarations, generated from the same sources |
