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

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.

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

Quick 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's useEffect, since it touches the DOM. Remember to destroy() 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 link marks 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 — with linkPreview: false no 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 with recaptchaSiteKey, or pass recaptchaSiteKey: false (attribute recaptcha-site-key) to turn reCAPTCHA off entirely.

  • Script loading. The Google reCAPTCHA script is injected lazily into the host document <head>grecaptcha attaches to window, 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() returns null and 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.com and https://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.

  1. Acquire. POST /api/v1/sessions returns the token. The widget captures it from either the response body (visitorToken) or the x-visitor-token response header, whichever is present. The body is preferred cross-origin, because the header is only readable when the server sends Access-Control-Expose-Headers: x-visitor-token.
  2. Reuse. Once captured, the token is attached as x-visitor-token on every following request for that session (messages, attachments, close, …).
  3. Persist. When persistSession is enabled (the default), the token is stored in localStorage under csirt-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 firebase config 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 (pass firebase: false on 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.js into each site's root and leave serviceWorkerPath at 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 handling

If 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 shared window.firebase global 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 point firebase.serviceWorkerPath at 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 from https://www.gstatic.com/firebasejs/<version> into the host document — firebase attaches to window, so it can't live inside the shadow root — and only once, no matter how many widgets are mounted. Pin a different SDK version with firebase.sdkVersion (default 12.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) makes getToken throw VersionError: 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 override sdkVersion, 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 getToken rejecting — the token resolves to null and the session is created without pushToken. 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.com in connect-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 typecheck

License

MIT