@trilok-zs/chat-component
v1.0.1
Published
Framework-independent chat Web Component (Custom Element + Shadow DOM) implemented internally with React + TypeScript. Completely API-agnostic: every endpoint, id, token and language is supplied by the consuming application.
Maintainers
Readme
@trilok-zs/chat-component
A production-ready chat UI, built internally with React + TypeScript, published as a framework-independent Web Component (Custom Element + Shadow DOM).
Consume it from Vue 3, Angular, Svelte, or plain HTML. React is bundled inside the package — the host application never installs or imports React.
The package is completely API-agnostic. It contains no endpoint, customer id, conversation id, auth token, or language. Every one of those is supplied by the consuming application through attributes, JS properties, or
configure().
<chat-component
endpoint="https://XXXXX/api/chat"
details-endpoint="https://XXXXX/api/details"
customer-id="XXXXX"
language="en"
></chat-component>Table of contents
- Install
- Quick start
- Attributes
- JavaScript properties
configure()- Public methods
- Events
- API contract
- Authentication
- Error handling
- Streaming / SSE
- Vue 3 integration
- Styling and Shadow DOM
- Project structure
- Build and publish
- Local development
1. Install
npm install @trilok-zs/chat-componentNo peer dependencies. No React in the host app.
2. Quick start
import '@trilok-zs/chat-component'; // registers <chat-component><chat-component
endpoint="https://XXXXX/api/chat"
details-endpoint="https://XXXXX/api/details"
customer-id="XXXXX"
language="en"
></chat-component>Registration happens as an import side effect. To register under a different tag name:
globalThis.CHAT_COMPONENT_DISABLE_AUTO_DEFINE = true;
import { defineChatComponent } from '@trilok-zs/chat-component';
defineChatComponent('acme-chat');3. Attributes
| Attribute | Required | Description |
| ---------------------- | ------------------- | ----------- |
| endpoint | for sending | POST URL for sending a message. |
| details-endpoint | for rendering | Base URL of the details API. Source of truth for the transcript. |
| customer-id | optional | Sent as customer_id. Missing → chat-error warning, request still sent with null. |
| language | optional | Sent as language and appended as ?language=. No default locale. |
| conversation-id | optional | Resumes an existing conversation; loads it automatically. |
| auth-token | optional | Sent as Authorization: Bearer <token>. |
| with-credentials | optional | Send cookies with both calls (credentials: 'include'). See Authentication. |
| credentials | optional | include | same-origin | omit. Long form of with-credentials. |
| theme | optional | light | dark | auto (default auto). |
| disabled | optional | Read-only transcript; composer disabled. |
| auto-load | optional | Default true. false disables automatic details loading. |
| refresh-from-details | optional | Default true. false skips the details call after a send (streaming). |
| title | optional | Header title text. |
| placeholder | optional | Composer placeholder text. |
All attributes are optional; endpoint and details-endpoint are required for API communication and produce typed errors when missing.
4. JavaScript properties
Every attribute has a camelCase property, plus headers and read-only messages.
const chat = document.querySelector('chat-component');
chat.endpoint = 'https://XXXXX/api/chat';
chat.detailsEndpoint = 'https://XXXXX/api/details';
chat.customerId = 'XXXXX';
chat.language = 'en';
chat.conversationId = 'a3f1…';
chat.authToken = 'TOKEN';
chat.headers = { 'X-Tenant': 'acme' };Properties do not reflect back to attributes, so they never fight a host framework binding the same attribute. Properties set before the element is connected are applied on connect, and take precedence over markup.
5. configure()
For anything not expressible as a string attribute:
chat.configure({
endpoint: '…',
detailsEndpoint: '…',
customerId: '…',
language: 'en',
conversationId: '…',
headers: { Authorization: 'Bearer …' },
extraPayload: { channel: 'web' }, // merged into the POST body
credentials: 'include', // cookie-based auth
timeoutMs: 30000, // 0/null disables
refreshFromDetails: true,
autoLoad: true,
theme: 'light',
labels: { title: 'Support', placeholder: 'Ask anything…' },
fetchImpl: myInstrumentedFetch,
transport: myTransport,
onEvent: (name, detail) => console.log(name, detail),
});Merge semantics: undefined leaves a field untouched, null clears it, objects (headers, labels, extraPayload) merge. Consumer values always override defaults.
getConfig() returns the resolved config; getState() returns { messages, status, error, conversationId, sfPanel, busy, lastFailedInput }.
6. Public methods
chat.sendMessage(message: string): Promise<void> // POST → details → render
chat.clearChat(): void // clears transcript + conversation id
chat.setConversationId(id: string): void // sets id, auto-loads it
chat.loadConversation(id?: string): Promise<void> // loads id, or the current one
chat.abort(): void // cancels the in-flight request
chat.getMessages(): ChatMessage[]
chat.getState() / chat.getConfig()sendMessage and loadConversation never reject — failures surface via chat-error and getState().error, so a fire-and-forget call from a template can't cause an unhandled rejection.
7. Events
All events are CustomEvents, bubbles: true and composed: true, so they cross the shadow boundary and can be caught on an ancestor.
| Event | event.detail |
| ------------------------ | -------------- |
| chat-message-sent | { conversationId, message, messages } |
| chat-response-received | { conversationId, message, messages, raw } |
| conversation-loaded | { conversationId, messages, sfPanel, raw } |
| conversation-changed | { conversationId, previousConversationId } |
| chat-error | { code, message, severity, status?, phase, retryable, details?, input? } |
import type { ChatResponseReceivedEvent } from '@trilok-zs/chat-component';
chat.addEventListener('chat-response-received', (event) => {
const { conversationId, messages } = (event as ChatResponseReceivedEvent).detail;
});8. API contract
POST {endpoint}
{
"id": "<conversationId or null>",
"content": "<user message>",
"customer_id": "<customerId>",
"attachment": null,
"language": "<language>",
"message": "<user message>"
}Only id is read from the response and becomes the current conversation id:
{ "id": "XXXXX", "content": "hello", "…": "…" }GET {detailsEndpoint}/{conversationId}?language={language}
{
"status": "success",
"message": "success",
"data": {
"chats": [
{ "role": "user", "content": "hello" },
{ "role": "assistant", "content": "Hello there!" }
],
"sf_panel": false
}
}Flow on send: POST → read id → save id → GET details → read data.chats → update UI. The POST response is never assumed to contain the assistant answer; the details API is the source of truth.
Responses are normalized into:
interface ChatMessage {
id?: string;
role: 'user' | 'assistant';
content: string;
timestamp?: string;
feedback?: unknown;
suggestion?: unknown;
}Only user and assistant turns render — system, tool, and unknown roles are dropped. content may be a string or a content-part array ([{ type, text }]).
9. Authentication
<chat-component auth-token="TOKEN" …></chat-component>sends Authorization: Bearer TOKEN on both calls. For other header schemes:
chat.configure({ headers: { Authorization: 'Basic …', 'X-Api-Key': '…' } });Cookie / session authentication
Browsers forbid setting a Cookie header from JavaScript — fetch silently drops it. Cookies are sent by opting into a credentials mode instead:
<!-- shorthand: credentials: 'include' -->
<chat-component with-credentials endpoint="…" details-endpoint="…"></chat-component>
<!-- or explicit -->
<chat-component credentials="include" endpoint="…" details-endpoint="…"></chat-component>chat.credentials = 'include'; // property
chat.configure({ credentials: 'include' }); // or configure()| Value | Behaviour |
| ----- | --------- |
| include | Send cookies on cross-origin requests too. Needed when the API is on a different origin than the app. |
| same-origin | Send cookies only for same-origin requests (browser default). |
| omit | Never send cookies. |
| unset / invalid | Browser default. |
Applied to both the POST and the details GET. If you pass a Cookie header anyway, the component logs a one-time warning explaining why it won't work rather than letting it look like a server-side auth failure.
The session itself belongs to the host app — the component never logs in. Establish the session (SSO redirect, login call with credentials: 'include'), then let the component ride the cookie.
For credentials: 'include' to work cross-origin, the API must:
- Return
Access-Control-Allow-Credentials: true. - Return
Access-Control-Allow-Origin: <your exact origin>— the wildcard*is rejected by browsers on credentialed requests. - Set the cookie with
SameSite=None; Secure(so HTTPS in any real deployment;http://localhostcounts as a secure context in dev). - Answer the
OPTIONSpreflight with those same headers, plusVary: Originif responses are cached.
Missing any of these shows up as a chat-error with NETWORK_ERROR (the browser blocks the response before the component sees a status) or HTTP_CLIENT_ERROR with status: 401. Try the mock server with REQUIRE_AUTH=1 npm run mock to see both paths.
No credential is ever stored or defaulted inside the package. Prefer short-lived tokens or HttpOnly cookies; refresh on a chat-error with status: 401.
10. Error handling
Every failure is a typed chat-error with a stable code:
| Code | Cause |
| ---- | ----- |
| MISSING_ENDPOINT / MISSING_DETAILS_ENDPOINT | Endpoint not supplied |
| MISSING_CUSTOMER_ID | No customer id (warning, request proceeds) |
| MISSING_CONVERSATION_ID | loadConversation() with no id |
| EMPTY_MESSAGE / BUSY | Invalid call (warnings) |
| NETWORK_ERROR / TIMEOUT / ABORTED | Transport failures |
| HTTP_CLIENT_ERROR / HTTP_SERVER_ERROR | 4xx / 5xx (with status and parsed body) |
| INVALID_POST_RESPONSE | Response not JSON, empty, or missing id |
| INVALID_DETAILS_RESPONSE | Missing data.chats, or status: "error" |
The UI shows an inline banner with Retry (which re-sends the failed text) and Dismiss. retryable tells the host whether retrying can help.
11. Streaming / SSE
The UI consumes transport events, never a raw HTTP response, so streaming is additive:
type ChatTransportEvent =
| { type: 'conversation'; conversationId: string }
| { type: 'delta'; content: string; messageId?: string }
| { type: 'message'; message: ChatMessage }
| { type: 'messages'; messages: ChatMessage[] }
| { type: 'done'; conversationId?: string | null };The default JSON transport emits conversation + done. A ready-made SSE transport ships with the package:
import { createSseTransport } from '@trilok-zs/chat-component';
chat.configure({
transport: createSseTransport(),
refreshFromDetails: false, // render from the stream instead of re-reading details
});createSseTransport({ mapEvent }) lets you map any server frame shape onto those events. Any object implementing ChatTransport works — the UI, state machine, and events stay unchanged.
12. Vue 3 integration
npm install @trilok-zs/chat-componentvite.config.ts — tell the Vue compiler this tag is a custom element:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === 'chat-component',
},
},
}),
],
});Vue CLI / webpack equivalent:
// vue.config.js
module.exports = {
chainWebpack: (config) => {
config.module.rule('vue').use('vue-loader').tap((options) => {
options.compilerOptions = {
...options.compilerOptions,
isCustomElement: (tag) => tag === 'chat-component',
};
return options;
});
},
};App.vue
<script setup lang="ts">
import '@trilok-zs/chat-component';
</script>
<template>
<chat-component
endpoint="https://XXXXX/api/chat"
details-endpoint="https://XXXXX/api/details"
customer-id="XXXXX"
language="en"
/>
</template>Events + TypeScript
<script setup lang="ts">
import '@trilok-zs/chat-component';
import type {
ChatElement,
ChatErrorEvent,
ChatResponseReceivedEvent,
} from '@trilok-zs/chat-component';
import { useTemplateRef } from 'vue';
const chat = useTemplateRef<ChatElement>('chat');
function handleResponse(event: Event) {
const { conversationId, messages } = (event as ChatResponseReceivedEvent).detail;
console.log(conversationId, messages);
}
function handleError(event: Event) {
const detail = (event as ChatErrorEvent).detail;
if (detail.status === 401) chat.value?.configure({ authToken: 'fresh-token' });
}
</script>
<template>
<chat-component
ref="chat"
:endpoint="endpoint"
:details-endpoint="detailsEndpoint"
@chat-response-received="handleResponse"
@chat-error="handleError"
/>
</template>Notes for Vue hosts:
- Vue binds string values as attributes on unknown elements, so
:endpoint="url"works; use.proporconfigure()for objects. - Vue's
@kebab-caselisteners map directly onto theCustomEventnames — no.nativeand no camelCase conversion. v-iftoggling is safe: the element unmounts React on disconnect and remounts with its configuration intact.- Optional template typing for the tag lives in
examples/vue-app/src/chat-component.d.ts.
A complete runnable host app is in examples/vue-app/.
13. Styling and Shadow DOM
The UI renders inside an open Shadow DOM with its CSS injected via a constructable stylesheet (with a <style> fallback). Host CSS cannot reach in; component CSS cannot leak out. Theme through custom properties, which do pierce the boundary:
chat-component {
--cc-height: 600px;
--cc-accent: #6d28d9;
--cc-user-bubble: #6d28d9;
--cc-radius: 16px;
--cc-font: 'Inter', system-ui, sans-serif;
}Full list: see :host in src/styles/chat.css (also shipped as dist/style.css).
UI features: header, message list, user/assistant bubbles, auto-growing textarea, send button, loading/error/empty states, auto-scroll (pauses while reading history), Enter to send, Shift+Enter for newline, send disabled during requests, stop button to cancel, typing indicator, prefers-color-scheme and prefers-reduced-motion support, ARIA live region.
14. Project structure
src/
components/ React — rendering and UI state only
Chat.tsx
ChatMessage.tsx
ChatInput.tsx
useChatController.ts
core/ framework-agnostic logic
ChatController.ts state machine: network, state, events
config.ts defaults + consumer merge semantics
errors.ts ChatError with stable codes
services/ generic API layer — receives URLs, never owns them
chatApi.ts postChatMessage / getConversationDetails
transport.ts default JSON transport
sseTransport.ts opt-in SSE/streaming transport
types/chat.ts public type contract
styles/chat.css Shadow DOM styles
web-component/
ChatElement.ts attributes, lifecycle, Shadow DOM, events, methods
index.ts public entry (registers <chat-component>)React renders; ChatController decides; ChatElement is the framework boundary. Because the controller is framework-agnostic, the element's public methods work even before React mounts.
15. Build and publish
npm install
npm run build
npm publishnpm run build type-checks, bundles to dist/index.js (React included), rolls all types into a single dist/index.d.ts, and emits dist/style.css.
dist/
index.js ES module, self-contained
index.js.map
index.d.ts single rolled-up declaration file, zero React types
style.css optional standalone stylesheetPublishing checklist:
- Own the scope.
@trilok-zsmust be either your npm username or an org you belong to (create one at https://www.npmjs.com/org/create). Otherwise publishing fails withE402/E403. - Log in:
npm login, then confirm withnpm whoami. - Dry run:
npm publish --dry-run— validates the tarball without uploading. - Publish:
npm publish.prepublishOnlyre-runs the build sodist/is never stale, andpublishConfig.access: publiccovers the first scoped publish. With 2FA enabled:npm publish --otp=123456. - Verify:
npm view @trilok-zs/chat-component, then install it in a host app.
Releasing again: npm version patch|minor|major && npm publish. A version can
never be republished, and unpublishing is only possible within 72 hours.
Publishing from CI instead of a laptop — create an Automation token on npm
(bypasses 2FA) and expose it as NODE_AUTH_TOKEN:
# .github/workflows/publish.yml
name: publish
on:
push:
tags: ['v*']
permissions:
contents: read
id-token: write # enables --provenance
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}To publish privately instead, set "access": "restricted" in
publishConfig (requires a paid npm plan or an org with private packages).
Then in the Vue application:
npm install @trilok-zs/chat-component16. Local development
npm install
npm run mock # terminal 1 — mock API on http://localhost:8787
npm run dev # terminal 2 — dev harness on http://localhost:3000The harness (demo/) plays the role of the consuming app: it configures endpoints at runtime and logs every event. It deliberately applies hostile global CSS to demonstrate Shadow DOM isolation.
npm run typecheck # strict TS, sources + build config
npm run build # typecheck + bundle + types + cssLicense
MIT
