npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@chattoo/rlb-chattoo-widget

v1.0.3

Published

Embeddable chat widget: one script tag on a site, or an npm package in an app

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-widget
import { 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 URL

What 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 declarations

npm 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 |