csirt-chat-widget
v0.3.3
Published
Framework-agnostic CSIRT live-chat widget (Web Component) with TipTap input and file attachments. Works in React, Next.js, Vue, or plain HTML.
Maintainers
Readme
CSIRT Chat Widget
A framework-agnostic live-chat widget for the CSIRT support API. It ships as a
Web Component (<csirt-chat-widget>), so the same package drops into
React, Next.js, Vue, Svelte, or plain HTML with no framework adapter.
- Rich text input powered by TipTap (
@tiptap/core) — messages are sent as TipTap document JSON, exactly the shape the CSIRT API expects. - Image / PDF file attachments.
- Clickable links — bare URLs in a message are auto-linkified, with optional rich link previews (og:image / title / description) resolved through the backend.
- Agent sender name shown above agent bubbles.
- Style-isolated (Shadow DOM) so it won't clash with the host page's CSS.
- Realtime via Socket.IO — new messages appear instantly and an animated "typing…" indicator shows while the agent is composing. REST polling stays as an automatic fallback when the socket is unavailable.
- Session persistence, unread badge, and a notification sound.
- Optional web push via Firebase Cloud Messaging — an FCM token is sent at session creation so the agent's replies can reach the visitor when the tab is in the background.
- Matches the CSIRT Pemda DIY design out of the box, fully themeable.
Install
npm install csirt-chat-widgetQuick start
Any bundler (React, Next.js, Vue, Svelte, Vite, …)
import { init } from "csirt-chat-widget";
init({
baseUrl: "http://localhost:3002",
apiKey: "csk_39fed37...", // your public X-API-Key
// token: "eyJ...", // optional Authorization: Bearer token
});Call it once, anywhere on the client (e.g. a top-level layout / useEffect).
The launcher button mounts itself in the bottom-right corner.
Next.js: run
init()inside a"use client"component'suseEffect, since it touches the DOM. Remember todestroy()on unmount in dev/StrictMode.
"use client";
import { useEffect } from "react";
import { init } from "csirt-chat-widget";
export function SupportWidget() {
useEffect(() => {
const widget = init({ baseUrl: "http://localhost:3002", apiKey: "csk_..." });
return () => widget.destroy();
}, []);
return null;
}Plain HTML — declarative
<script type="module" src="https://unpkg.com/csirt-chat-widget/dist/csirt-chat-widget.js"></script>
<csirt-chat-widget
base-url="http://localhost:3002"
api-key="csk_39fed37...">
</csirt-chat-widget>Plain HTML — classic <script> (no modules)
<script src="https://unpkg.com/csirt-chat-widget/dist/csirt-chat-widget.iife.js"></script>
<script>
CSIRTChatWidget.init({
baseUrl: "http://localhost:3002",
apiKey: "csk_39fed37...",
});
</script>Configuration
| Option | Type | Default | Description |
| ------------------- | ----------------------------- | -------------- | -------------------------------------------------------- |
| baseUrl | string | — | Required. CSIRT API base URL. |
| publicKey | string | — | Required. Sent as X-Public-Key on every request. (apiKey is a legacy alias.) |
| apiKey | string | — | Deprecated alias for publicKey. |
| token | string | — | Optional Authorization: Bearer token (e.g. Keycloak access token). When present the visitor is logged in; when absent, anonymous (see Authentication & security). |
| recaptchaSiteKey | string \| false | CSIRT site key | reCAPTCHA v3 site key. A fresh x-recaptcha-token is minted per protected request. false disables reCAPTCHA. See reCAPTCHA v3. |
| firebase | FirebasePushConfig \| boolean| baked-in defaults | Firebase Cloud Messaging (web push). The widget ships defaults for the shared CSIRT project, so push is on by default: it requests an FCM token (prompting for notification permission) and sends it as pushToken on session creation. false disables it; an object overrides the defaults. Requires an FCM service worker on the host origin. See Push notifications. |
| target | string \| HTMLElement | document.body| Where to mount the widget element. |
| openOnLoad | boolean | false | Open the panel immediately. |
| pollInterval | number (ms) | 4000 | Fallback polling cadence when the socket is down. 0 disables polling. |
| socket | boolean | true | Use Socket.IO for realtime messages + typing. Pauses polling while connected. |
| socketUrl | string | baseUrl | Socket.IO server URL, if different from baseUrl. |
| socketPath | string | /socket.io | Socket.IO endpoint path. |
| maxAttachmentSize | number (bytes) | 10485760 | Max attachment size (10 MB). |
| sound | boolean | true | Play a sound on incoming agent messages. |
| persistSession | boolean | true | Persist session id in localStorage across reloads. |
| linkPreview | boolean \| string \| (url) => Promise<LinkPreview \| null> | true | Rich og:image/title/description previews for URLs. true uses ${baseUrl}/api/v1/link-preview?url=; pass a string for a custom endpoint, a function for a custom resolver, or false to disable (URLs still render as clickable links). |
| feedbackUrl | string | https://s.jogjaprov.go.id/feedback | External page the post-session notice points at. "" skips the notice. |
| theme | WidgetTheme | — | Colours (see below). |
| labels | WidgetLabels | Indonesian | Override any UI string. |
| onMessage | (msg) => void | — | Fired on each incoming agent message. |
| onAgentTyping | (typing) => void | — | Fired when the agent starts/stops typing. |
| onSessionStart | (session) => void | — | Fired when a session is created. |
| onSessionEnd | (session \| null) => void | — | Fired when the session ends. |
Theming
init({
baseUrl, apiKey,
theme: {
gradient: "linear-gradient(105deg, #2f6df6 0%, #23b26d 100%)",
primary: "#2f6df6",
background: "#f4f7fa",
agentBubble: "#ffffff",
agentText: "#1f2937",
userText: "#ffffff",
},
});Labels
All copy defaults to Indonesian (matching the reference design) and can be overridden:
init({
baseUrl, apiKey,
labels: {
title: "Bantuan Langsung",
subtitle: "CSIRT Pemda DIY",
inputPlaceholder: "Ketik pesan…",
endedByAgent: "Percakapan telah diakhiri oleh agen.",
newConversation: "Mulai Percakapan Baru",
},
});Link previews (open graph)
Any http(s) URL in a message — from the visitor or from the agent/admin on Discord — gets a preview card under the bubble. Detection covers:
- bare URLs in plain-text messages, including Discord's embed-suppress form
<https://example.com>; - URLs inside a TipTap doc, including
linkmarks whose anchor text is a label rather than the URL itself ([Klik di sini](https://example.com)sent by an admin); - at most 3 cards per message, deduplicated by URL.
Each URL is resolved through GET ${baseUrl}/api/v1/link-preview?url=<encoded>
(with the widget's auth headers — browsers can't scrape cross-origin pages
themselves), then cached in memory and re-applied on every re-render. A URL that
came back without metadata is retried after a minute rather than being
written off for the page's lifetime, so a link whose target was briefly slow (a
shortener pointing at a cold-starting app, say) still gets its card. The
response is normalized from the usual field spellings (title/ogTitle,
description/ogDescription, image/ogImage/thumbnail, siteName, url);
a relative og:image is resolved against the page URL. If the endpoint fails or
returns no usable metadata, the placeholder is removed and the plain clickable
link remains. If the og:image itself fails to load, only the image strip is
dropped.
Point linkPreview at your own endpoint (string) or resolver function, or set it
to false to disable the cards entirely.
Pre-chat form
Before a session starts, the widget collects:
| Field | Logged in | Anonymous | Notes |
| ---------------- | --------- | --------- | ------------------------------------------------------------ |
| Nama | — | required | name in the POST /sessions body. |
| Email | — | required | email; validated for a basic [email protected] shape. |
| Instansi | — | optional | Free text. Sent as institution (a plain string). |
| Kategori Layanan | required | required | serviceCategoryId, from GET /api/v1/service-categories. |
The institution field used to be a combo box backed by
GET /api/v1/institutions; it is now a plain text input, so the widget no longer
calls that endpoint (the listInstitutions() client method and the legacy
institutionId field on PrechatData remain for compatibility).
Once the required fields are filled, Mulai Percakapan moves the visitor to the composer. The picked service category is auto-sent as the opening message — except for "Lainnya", where the visitor types their own opening message instead.
Feedback notice
When a session ends — closed by the visitor or by the agent — the widget no longer collects a rating in-widget. It shows a notice pointing the visitor at an external feedback page:
Terima kasih telah menggunakan layanan kami. Mohon kesediaan Anda untuk memberikan penilaian dan masukan terkait layanan yang telah diterima melalui https://s.jogjaprov.go.id/feedback.
The notice renders:
- the message, with the URL as a clickable link (
labels.feedbackMessage, where{url}is replaced by the link); - an og link-preview card for that URL, resolved through the same
link-preview pipeline as URLs in messages (so it obeys
linkPreview— withlinkPreview: falseno card is shown); - a Beri Penilaian button that opens the page in a new tab;
- Tutup, which dismisses the notice and shows the ended screen.
The URL is feedbackUrl (default https://s.jogjaprov.go.id/feedback, also
settable via the feedback-url attribute). Set feedbackUrl: "" to skip the
notice entirely and go straight to the ended screen.
No feedback is sent to the API: the POST /sessions/:id/feedback call, the
onFeedbackSubmit callback, and the FeedbackScore type were removed.
Programmatic control
init() returns a handle:
const widget = init({ baseUrl, apiKey });
widget.open();
widget.close();
widget.toggle();
widget.destroy();API surface used
The widget talks to these documented endpoints:
| Action | Method & path |
| ------------- | ---------------------------------------------- |
| Start session | POST /api/v1/sessions |
| Get session | GET /api/v1/sessions/:id |
| Close session | POST /api/v1/sessions/:id/close |
| Send message | POST /api/v1/sessions/:id/messages |
| Send file | POST /api/v1/sessions/:id/attachments |
| List messages | GET /api/v1/sessions/:id/messages?limit=100 |
| Force reset | POST /api/v1/sessions/:id/force-reset |
The first message a user sends is used as the session's initialMessage;
subsequent messages use the messages endpoint.
Realtime runs over a Socket.IO connection to socketUrl (defaults to
baseUrl). On connect the widget joins the session room (session:join) and
listens for message:created, session:updated, session:closed, and
agent:typing; it emits typing while the visitor composes. If the socket
can't connect (or drops), the widget automatically falls back to polling the
list + session endpoints at pollInterval.
Note on response shapes: the backend's exact JSON schema for messages isn't fully specified, so responses are passed through a defensive normalizer (
src/api/normalize.ts) that handles several common field names (sender/role/direction,content/message/body, etc.). If your API differs, adjust the field lists there.
Authentication & security
Every request the widget makes carries the public key. On top of that, the widget adapts to whether the visitor is logged in or anonymous, and mints a reCAPTCHA token for the sensitive endpoints.
Which headers go out
| Header | Logged in (has token) | Anonymous | Sent on |
| ------------------ | :---------------------: | :-------: | --------------------------------------------------- |
| X-Public-Key | ✅ | ✅ | Every request. |
| Authorization | ✅ Bearer <token> | — | Every request, when a token is configured. |
| x-visitor-token | — | ✅ once acquired | Every request, once the session hands one back. |
| x-recaptcha-token| ✅ | ✅ | Only the protected, write endpoints (see below).|
The bearer token and the visitor token are mutually exclusive: if a token is
configured the visitor is logged in and x-visitor-token is never sent; without
one the widget operates anonymously.
Header assembly lives in CsirtApiClient.headers()
(src/api/client.ts).
reCAPTCHA v3
The backend expects an x-recaptcha-token on the state-changing endpoints.
Tokens are short-lived and single-use, so the widget mints a fresh one per
request via grecaptcha.execute(siteKey, { action })
(src/api/recaptcha.ts).
Site key. Defaults to the CSIRT Pemda DIY key (
6Lf9flEtAAAAAGftUjbUN7ASIM8x5j0A45hbluIc). Override withrecaptchaSiteKey, or passrecaptchaSiteKey: false(attributerecaptcha-site-key) to turn reCAPTCHA off entirely.Script loading. The Google reCAPTCHA script is injected lazily into the host document
<head>—grecaptchaattaches towindow, so it cannot live inside the widget's shadow root — and only once, no matter how many widget instances are mounted or how many tokens are requested (loaders are shared per site key).Per-request actions. Each endpoint mints a token under its own action name, which you can score/threshold independently in the reCAPTCHA admin console:
| Endpoint | reCAPTCHA action | | --------------------------------- | ----------------- | |
POST /sessions|create_session| |POST /sessions/:id/close|close_session| |POST /sessions/:id/messages|send_message| |POST /sessions/:id/attachments|send_attachment|Read-only calls (get/list session, messages, service categories, institutions, attachment download, link preview) send no reCAPTCHA token.
Graceful degradation. If the script fails to load or the challenge can't be minted,
getRecaptchaToken()returnsnulland the request goes out without the header rather than blocking the user — the backend makes the final call on whether to accept it.
CSP note: allow
https://www.google.comandhttps://www.gstatic.com(script-src/frame-src) on the host page, or the reCAPTCHA script won't load and every protected request will fall back to unauthenticated.
Visitor token (anonymous sessions)
Anonymous visitors have no bearer token, so the API issues a per-session visitor token at session creation that authorizes every subsequent request in that session.
- Acquire.
POST /api/v1/sessionsreturns the token. The widget captures it from either the response body (visitorToken) or thex-visitor-tokenresponse header, whichever is present. The body is preferred cross-origin, because the header is only readable when the server sendsAccess-Control-Expose-Headers: x-visitor-token. - Reuse. Once captured, the token is attached as
x-visitor-tokenon every following request for that session (messages, attachments, close, …). - Persist. When
persistSessionis enabled (the default), the token is stored inlocalStorageundercsirt-cw:visitor:<baseUrl>alongside the session id (csirt-cw:session:<baseUrl>), so a page reload resumes the same anonymous session. Clearing/ending the session removes both keys.
If a session is created but no visitor token can be found in the body or headers,
the widget logs a warning — subsequent anonymous requests would otherwise go out
unauthenticated. Check your API's response shape and
Access-Control-Expose-Headers.
Recovering a stuck session. If the backend later rejects a session with
"Missing bearer token or valid visitor token", the widget calls
POST /api/v1/sessions/:id/force-reset. Per the API contract this call sends
only the public key — the stale bearer/visitor token is deliberately omitted,
since that is exactly what the server is refusing — and returns the visitor to a
fresh pre-chat form.
Push notifications (Firebase Cloud Messaging)
The widget obtains a Firebase Cloud Messaging (FCM) registration token for
the browser and sends it as pushToken in the POST /api/v1/sessions body, so
your backend can push the agent's replies to the visitor via web push while the
tab is backgrounded.
The package ships defaults for the shared CSIRT Firebase project, so push is on with zero config — you only need to host the service worker:
init({ baseUrl: "http://localhost:3002", apiKey: "csk_..." });
// push enabled by default; `firebase: false` turns it off.Pass a firebase object only to override the defaults — e.g. to point at a
different Firebase project, pin the SDK version, or set a custom service-worker
path:
init({
baseUrl: "http://localhost:3002",
apiKey: "csk_...",
firebase: {
// Override the project (Project settings → General → Your apps). Omit to
// keep the baked-in shared CSIRT project.
config: {
apiKey: "AIza...",
authDomain: "your-app.firebaseapp.com",
projectId: "your-app",
messagingSenderId: "1234567890",
appId: "1:1234567890:web:abcdef",
},
// Override the Web Push certificate key pair
// (Project settings → Cloud Messaging → Web Push certificates).
vapidKey: "BJ...",
// Path to your FCM service worker on the host origin.
// Defaults to "/firebase-messaging-sw.js".
serviceWorkerPath: "/firebase-messaging-sw.js",
},
});firebase accepts a boolean or a (partial) object, so it can only be set via the
.config property / init() — not as an HTML attribute. Unspecified object
fields fall back to the baked-in defaults.
One backend, many front ends
The widget is designed to be embedded across several front-end sites that all talk to a single CSIRT backend — and therefore a single Firebase project. That's exactly why the project config is baked into the package. That means:
- No
firebaseconfig on any site — the shared project config and VAPID key ship with the widget, so every front end gets push automatically. There's no per-site config to set or keep in sync (passfirebase: falseon a site that should opt out). - The service worker file is identical on every origin (same project), so you
copy the same
firebase-messaging-sw.jsinto each site's root and leaveserviceWorkerPathat its default. A host only needs a custom path if it already runs FCM for a different project (see If your app already uses Firebase). - Each origin must still host that worker itself — a service worker script must be served same-origin, so the widget can't ship it from a CDN on your behalf. It's the same file each time, just deployed per site.
Add every front-end domain to the Firebase project's authorized domains (Console → Authentication → Settings) if you also use Firebase Auth features; plain FCM web push only requires each site to be served over HTTPS.
You must host a service worker
FCM web push requires a service worker served from your origin's root scope,
so it must be reachable at https://your-site/firebase-messaging-sw.js. It's
identical on every front end (shared CSIRT project), so the package ships a
ready-made copy — grab it from the install rather than writing your own:
cp node_modules/csirt-chat-widget/dist/firebase-messaging-sw.js public/If you'd rather create it by hand, here's the file — copy it verbatim into each site's public root:
// public/firebase-messaging-sw.js — served at https://your-site/firebase-messaging-sw.js
importScripts("https://www.gstatic.com/firebasejs/12.16.0/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/12.16.0/firebase-messaging-compat.js");
// Shared CSIRT Firebase project — same values the widget uses.
firebase.initializeApp({
apiKey: "AIzaSyAhYNk-nPQNK3CSxWfZ9r_rAf4p1p2E1Ds",
authDomain: "csirt-chat-service.firebaseapp.com",
projectId: "csirt-chat-service",
storageBucket: "csirt-chat-service.firebasestorage.app",
messagingSenderId: "673688147555",
appId: "1:673688147555:web:413ec72a30e13323860a56",
});
firebase.messaging(); // enables background message handlingIf your app already uses Firebase
The widget won't collide with a host page that already uses Firebase:
- Isolated app. It initializes a dedicated named Firebase app
(
"csirt-chat-widget") and never touches your default app. This matters with the compat SDK, where Firebase is a sharedwindow.firebaseglobal whose default app holds your project config — grabbing it would mint a token for the wrong project. Modular (bundled) Firebase doesn't expose a global at all, so it's independent regardless. - Separate service worker for a separate project. FCM background messages are
delivered by
firebase-messaging-sw.js, which is bound to a single project. If your app already ships an FCM service worker for a different Firebase project, host the widget's worker at its own path (e.g./csirt-firebase-messaging-sw.js, initialized with the widget's project) and pointfirebase.serviceWorkerPathat it — otherwise the shared worker's project won't match the widget's token and background pushes won't be handled. - Duplicate download. If your app bundles the modular SDK, the widget still loads the compat SDK from the CDN separately. That's a small extra download but avoids assuming anything about your build.
How it works
- Script loading. The Firebase compat SDK
(
firebase-app-compat.js+firebase-messaging-compat.js) is injected lazily fromhttps://www.gstatic.com/firebasejs/<version>into the host document —firebaseattaches towindow, so it can't live inside the shadow root — and only once, no matter how many widgets are mounted. Pin a different SDK version withfirebase.sdkVersion(default12.16.0). Keep it on the v11+/v12 line: Firebase Messaging's origin-scoped IndexedDB (firebase-messaging-database) uses schema version 2 there, and pinning an older v10 SDK (DB version 1) makesgetTokenthrowVersionError: The requested version (1) is less than the existing version (2)whenever a newer Firebase SDK on the same origin (e.g. the host page's) already created that DB at version 2. If you overridesdkVersion, update the service worker to the same version so the two never diverge. - Permission. The widget calls
Notification.requestPermission()before requesting a token; if the user denies (or has denied) notifications, no token is sent. - Graceful degradation. On any failure — unsupported browser (e.g. some iOS
versions), missing Notifications/service-worker APIs, SDK load error, denied
permission, or
getTokenrejecting — the token resolves tonulland the session is created withoutpushToken. Push setup never blocks a visitor from chatting (src/api/firebase.ts).
CSP note: allow
https://www.gstatic.com(script-src) and your Firebase endpoints (https://*.googleapis.com,https://fcmregistrations.googleapis.cominconnect-src) on the host page, or token registration will fail and the session falls back to no push.
Development
npm install
npm run dev # playground at http://localhost:5173
npm run build # emits dist/ (ESM, UMD, IIFE) + type declarations
npm run typecheckLicense
MIT
