From 02ecf9a12eb47c249467b0e887913afc818556f7 Mon Sep 17 00:00:00 2001 From: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:45:39 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat(widget):=20embeddable,=20brandable=20c?= =?UTF-8?q?hat=20widget=20=E2=80=94=20rocketride-chat-widget=20(#1582)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New workspace package packaging RocketRide's existing public chat surface as something teams can put on their own sites. One ~72 KB (21 KB gzip) React-free bundle, two consumption modes: - web component (shadow DOM, style isolation both ways) rendered inline anywhere; attributes are observed live (title, accent, welcome, placeholder, theme=light|dark| auto), theming via --rr-* CSS custom properties, default accent #5f2167. - One-script-tag floating bubble: data-* attributes on the script tag configure a launcher button that opens a panel hosting the same element (position, Escape-to-close, aria-expanded). Auth uses ONLY the pipeline's public authorization key — the same {host}/chat?auth={public_auth} mechanism the chat source node already publishes; JSDoc and docs state explicitly that the engine API key and private token must never appear in a page. The connection layer reuses the browser-compatible 'rocketride' TS SDK (bundled from source, Node 'ws' path stubbed; SDK itself untouched), maps live SSE status to a 'thinking' indicator, and keeps a 6-turn history per question. Assistant output goes through an escape-first safe renderer (allowlist: paragraphs, line breaks, inline/fenced code, bold, http(s)-only links with rel="noopener noreferrer") — no raw model HTML ever reaches the DOM. A11y: role=log with aria-live=polite, labeled controls, Enter/ Shift+Enter, focus-visible outlines, reduced-motion aware. 112 jsdom tests (XSS corpus, component states, theming, loader config, connection protocol) plus a demo page for both modes. Co-Authored-By: Claude Fable 5 --- packages/chat-widget/.gitignore | 4 + packages/chat-widget/README.md | 318 ++++++++++ packages/chat-widget/demo/index.html | 241 ++++++++ packages/chat-widget/esbuild.js | 114 ++++ packages/chat-widget/jest.config.js | 69 +++ packages/chat-widget/package.json | 66 ++ packages/chat-widget/scripts/tasks.js | 111 ++++ packages/chat-widget/src/component.ts | 572 ++++++++++++++++++ packages/chat-widget/src/connection.ts | 245 ++++++++ packages/chat-widget/src/entry-iife.ts | 61 ++ packages/chat-widget/src/index.ts | 54 ++ packages/chat-widget/src/loader.ts | 487 +++++++++++++++ packages/chat-widget/src/render.ts | 166 +++++ packages/chat-widget/src/stubs/ws.ts | 33 + packages/chat-widget/src/styles.ts | 396 ++++++++++++ packages/chat-widget/src/types.ts | 100 +++ packages/chat-widget/tests/component.test.ts | 511 ++++++++++++++++ packages/chat-widget/tests/connection.test.ts | 184 ++++++ packages/chat-widget/tests/helpers.ts | 154 +++++ packages/chat-widget/tests/loader.test.ts | 361 +++++++++++ packages/chat-widget/tests/render.test.ts | 257 ++++++++ packages/chat-widget/tests/theming.test.ts | 191 ++++++ packages/chat-widget/tsconfig.json | 26 + packages/chat-widget/tsconfig.test.json | 25 + packages/chat-widget/tsconfig.types.json | 11 + pnpm-lock.yaml | 362 ++++++++++- pnpm-workspace.yaml | 1 + 27 files changed, 5118 insertions(+), 2 deletions(-) create mode 100644 packages/chat-widget/.gitignore create mode 100644 packages/chat-widget/README.md create mode 100644 packages/chat-widget/demo/index.html create mode 100644 packages/chat-widget/esbuild.js create mode 100644 packages/chat-widget/jest.config.js create mode 100644 packages/chat-widget/package.json create mode 100644 packages/chat-widget/scripts/tasks.js create mode 100644 packages/chat-widget/src/component.ts create mode 100644 packages/chat-widget/src/connection.ts create mode 100644 packages/chat-widget/src/entry-iife.ts create mode 100644 packages/chat-widget/src/index.ts create mode 100644 packages/chat-widget/src/loader.ts create mode 100644 packages/chat-widget/src/render.ts create mode 100644 packages/chat-widget/src/stubs/ws.ts create mode 100644 packages/chat-widget/src/styles.ts create mode 100644 packages/chat-widget/src/types.ts create mode 100644 packages/chat-widget/tests/component.test.ts create mode 100644 packages/chat-widget/tests/connection.test.ts create mode 100644 packages/chat-widget/tests/helpers.ts create mode 100644 packages/chat-widget/tests/loader.test.ts create mode 100644 packages/chat-widget/tests/render.test.ts create mode 100644 packages/chat-widget/tests/theming.test.ts create mode 100644 packages/chat-widget/tsconfig.json create mode 100644 packages/chat-widget/tsconfig.test.json create mode 100644 packages/chat-widget/tsconfig.types.json diff --git a/packages/chat-widget/.gitignore b/packages/chat-widget/.gitignore new file mode 100644 index 000000000..fb68f9e43 --- /dev/null +++ b/packages/chat-widget/.gitignore @@ -0,0 +1,4 @@ +dist/ +coverage/ +node_modules/ +*.tgz diff --git a/packages/chat-widget/README.md b/packages/chat-widget/README.md new file mode 100644 index 000000000..89722bd27 --- /dev/null +++ b/packages/chat-widget/README.md @@ -0,0 +1,318 @@ +

RocketRide Chat Widget

+ +

+ Embed a brandable AI chat on any web page — one script tag, zero frameworks. +

+ +

+ npm + GitHub + Discord + MIT License +

+ +`rocketride-chat-widget` is a framework-free chat UI for [RocketRide](https://rocketride.org) pipelines. One lean browser bundle (no React, no CSS frameworks) gives you two consumption modes: + +- **Floating chat bubble** — a single ` +``` + +That is the whole integration: a launcher bubble appears in the chosen corner, clicking it opens the chat panel, and Escape closes it again. The loader reads its configuration from the script tag's own `data-*` attributes. + +> Use a **classic** script tag (`defer` is fine). With `type="module"` the browser leaves `document.currentScript` unset, so the loader cannot find its configuration and no bubble is mounted. Pin a version for production, e.g. `https://unpkg.com/rocketride-chat-widget@1.3.0/dist/rocketride-chat.js`. + +### Option 2 — inline web component + +The same bundle also registers the `` custom element (with a plain script tag, just omit `data-engine-url` if you don't want the bubble too): + +```html + + +
+ + +
+``` + +The element fills its container (and keeps a 320px minimum height), so give it a sized parent. + +With a bundler, install the package and import it once — the import registers the element: + +```bash +# NPM +npm install rocketride-chat-widget +# Yarn +yarn add rocketride-chat-widget +# PNPM +pnpm add rocketride-chat-widget +``` + +```typescript +import 'rocketride-chat-widget'; // registers +``` + +Don't have a pipeline yet? Visit [RocketRide on GitHub](https://github.com/rocketride-org/rocketride-server) or download the extension directly in your IDE. + +## What is RocketRide? + +[RocketRide](https://rocketride.org) is an open source, developer-native AI pipeline platform. +It lets you build, debug, and deploy production AI workflows without leaving your IDE - +using a visual drag-and-drop canvas or code-first with TypeScript and Python SDKs. + +- **50+ ready-to-use nodes** - 13 LLM providers, 8 vector databases, OCR, NER, PII anonymization, and more +- **High-performance C++ engine** - production-grade speed and reliability +- **Deploy anywhere** - locally, on-premises, or self-hosted with Docker +- **MIT licensed** - fully open source, OSI-compliant + +You build your `.pipe` - and the widget puts a chat UI on it, anywhere on the web. + +## Features + +- **Two modes, one bundle** - inline `` web component and script-tag launcher bubble +- **Framework-free** - vanilla TypeScript web component; no React, no runtime dependencies +- **Style isolation** - shadow DOM keeps host CSS out and widget CSS in +- **Brandable** - accent color, title, welcome text, placeholder via attributes; full theming via CSS custom properties +- **Light / dark / auto** - `auto` follows `prefers-color-scheme` and updates live +- **Live status** - connecting / online / offline states, a "thinking" line with the pipeline's live status while it works, and an error banner with a Retry button +- **Safe output rendering** - escape-first formatter for assistant text (paragraphs, code, bold, http(s) links only); raw model output is never injected as HTML +- **Accessible** - `role="log"` with `aria-live="polite"`, labeled controls, keyboard support, focus management, reduced-motion support +- **Public-key auth only** - designed so no private credential ever ships to the browser + +--- + +## Security: public auth key only + +**The widget must only ever be configured with a pipeline's PUBLIC Authorization Key.** + +- **Where to find it:** when a pipeline with a chat source node is running, the node publishes a link of the form `{host}/chat?auth={public_auth}` along with the key itself (labeled _Public Authorization Key_, prefixed `pk_`). That `pk_…` value is what goes into the widget's `auth` / `data-auth`. +- **What it grants:** the public key is scoped to that one running pipeline's chat interface. It both authenticates the connection and addresses the pipeline — the widget needs nothing else. +- **What must never appear in a page:** the RocketRide engine API key (`ROCKETRIDE_APIKEY`) or any private task token (`tk_…`). Anything in an HTML attribute is readable by every visitor via _View Source_. The widget also never falls back to ambient environment credentials — the only credential it will ever send is the one you set explicitly. +- **Treat it like a public endpoint:** anyone with the page (and therefore the key) can chat with that pipeline. Restarting the pipeline issues a new public key, so an old key can be retired by republishing. Apply the same rate limiting / abuse protection you would give any public form. + +### Exposing an engine to browsers (CORS and TLS) + +- **CORS.** By default the engine's web endpoints accept requests from any `localhost` / `127.0.0.1` origin (any port) — enough for local development. To embed the widget on a real site, set the `RR_CORS_ORIGINS` environment variable on the engine to a comma-separated list of allowed origins (e.g. `RR_CORS_ORIGINS=https://www.example.com`). +- **TLS / mixed content.** Browsers block insecure connections from `https` pages. If the embedding page is served over `https`, the `engine-url` must be `https` too (the SDK upgrades it to a secure WebSocket automatically) — in practice, put the engine behind a TLS-terminating reverse proxy and use that URL. +- **Don't expose more than you need.** The page only needs to reach the engine's chat endpoint; keep engine management interfaces off the public network. + +--- + +## `` attributes + +All attributes are observed — changing them on a live element takes effect immediately. Changing `engine-url` or `auth` tears down the connection and reconnects. + +| Attribute | Required | Default | Description | +| ------------- | -------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `engine-url` | Yes | - | RocketRide engine URL, e.g. `http://localhost:5565`. `http(s)` or `ws(s)` accepted; converted to WebSocket internally. | +| `auth` | Yes | - | The pipeline's **PUBLIC** Authorization Key (`pk_…`). Never an engine API key or private token — see [Security](#security-public-auth-key-only). | +| `title` | No | `RocketRide Assistant` | Header title. Note: `title` is also a global HTML attribute, so browsers additionally show it as a hover tooltip on the element. | +| `accent` | No | `#5f2167` (RocketRide violet) | Brand accent color; any CSS color value. Shorthand for setting `--rr-accent`. | +| `welcome` | No | (none) | Assistant-styled welcome bubble shown before the first exchange. Not sent to the pipeline as history. | +| `placeholder` | No | `Type a message…` | Input placeholder text. | +| `theme` | No | `auto` | `light` \| `dark` \| `auto`. `auto` follows `prefers-color-scheme` and updates live when the OS theme changes. | + +The element connects when both `engine-url` and `auth` are present and it is attached to the document. Until then it renders in the idle/offline state. + +## Script-tag loader (`data-*` attributes) + +The IIFE bundle auto-initializes the bubble when its own ` + + + + + diff --git a/packages/chat-widget/esbuild.js b/packages/chat-widget/esbuild.js new file mode 100644 index 000000000..2b89dd331 --- /dev/null +++ b/packages/chat-widget/esbuild.js @@ -0,0 +1,114 @@ +// ============================================================================= +// MIT License +// Copyright (c) 2026 Aparavi Software AG +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// ============================================================================= + +/** + * chat-widget bundle build (mirrors apps/vscode/esbuild.js conventions). + * + * Produces the two single-file browser bundles from one source tree: + * dist/rocketride-chat.mjs — ESM, for bundler/import consumers (web-component mode); + * .mjs so Node parses it as ESM despite the package's + * "type": "commonjs" (kept for the CJS build scripts) + * dist/rocketride-chat.js — IIFE, for + * + * ``` + * + * 2. Launcher bubble (opt-in): when the script tag itself carries + * `data-engine-url`, the loader auto-mounts a floating launcher bubble + * configured from the tag's `data-*` attributes (see `loader.ts`). + * + * `document.currentScript` is read here, during the synchronous top-level + * evaluation of the bundle — it is available for classic scripts including + * `defer`red ones (but would be `null` in a `type="module"` script, which is + * why the bubble mode requires the IIFE bundle). + * + * SECURITY: `auth` / `data-auth` must always be the pipeline's PUBLIC + * authorization key (the `{public_auth}` from the chat node's + * `{host}/chat?auth={public_auth}` link) — never the engine API key. + * + * @module entry-iife + */ + +import { register } from './index'; +import { initFromScript } from './loader'; + +// Register (no-op if the element is already defined, e.g. +// when the bundle is accidentally included twice). +register(); + +// Auto-mount the launcher bubble when this script tag is configured for it. +initFromScript(); diff --git a/packages/chat-widget/src/index.ts b/packages/chat-widget/src/index.ts new file mode 100644 index 000000000..d06916770 --- /dev/null +++ b/packages/chat-widget/src/index.ts @@ -0,0 +1,54 @@ +/** + * MIT License + * + * Copyright (c) 2026 Aparavi Software AG + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * RocketRide Chat Widget — public entry point (ESM bundle). + * + * Importing this module registers the custom element + * (guarded, so double-loading the bundle is harmless) and exports the + * building blocks for programmatic use. + * + * AUTH MODEL: the widget is configured with the pipeline's PUBLIC auth key + * only (the `?auth=` value published by the chat source node) — never an + * engine API key or private token. See component.ts and connection.ts. + * + * @packageDocumentation + */ + +import { defineRocketRideChat } from './component'; + +export { RocketRideChatElement, WIDGET_TAG, defineRocketRideChat } from './component'; +export { WidgetConnection, extractAnswerTexts, HISTORY_LIMIT } from './connection'; +export type { WidgetConnectionOptions } from './connection'; +export { escapeHtml, renderMessageHtml } from './render'; +export { WIDGET_STYLES, DEFAULT_ACCENT } from './styles'; +export type { ChatClientFactory, ChatClientLike, ChatHistoryItem, ChatMessage, ChatRole, ConnectionState, ErrorEventDetail, MessageEventDetail, ThemeSetting } from './types'; +export { mountChatBubble, parseLoaderConfig, initFromScript } from './loader'; +export type { BubblePosition, BubbleTheme, ChatBubbleHandle, LoaderConfig } from './loader'; + +/** Alias of {@link defineRocketRideChat} kept for embed-loader compatibility. */ +export const register = defineRocketRideChat; + +// Register on import (guarded — double-loading is harmless). +defineRocketRideChat(); diff --git a/packages/chat-widget/src/loader.ts b/packages/chat-widget/src/loader.ts new file mode 100644 index 000000000..445e583c9 --- /dev/null +++ b/packages/chat-widget/src/loader.ts @@ -0,0 +1,487 @@ +/** + * MIT License + * + * Copyright (c) 2026 Aparavi Software AG + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * RocketRide Chat Widget — script-tag loader. + * + * Turns a single ` + * ``` + * + * SECURITY — public auth key ONLY: + * `data-auth` must be the pipeline's PUBLIC authorization key — the same + * value the chat source node publishes in its `{host}/chat?auth={public_auth}` + * link. It is scoped to a single running pipeline's chat endpoint. NEVER put + * the RocketRide engine API key or any private token into a web page: every + * visitor can read it. + * + * The launcher and panel are rendered inside a shadow root so host-page CSS + * cannot restyle them (and widget CSS cannot leak out). Theming is exposed + * through CSS custom properties (`--rr-accent`, `--rr-font`, `--rr-radius`) + * set on the bubble host element, which inherit into the shadow tree and the + * embedded `` component. + * + * @module loader + */ + +import { DEFAULT_ACCENT } from './styles'; + +/** Tag name of the chat web component this loader mounts. */ +export const CHAT_TAG = 'rocketride-chat'; + +/** Default brand accent (RocketRide violet), shared with styles.ts. */ +export { DEFAULT_ACCENT }; + +/** Default panel/dialog title. */ +export const DEFAULT_TITLE = 'RocketRide Assistant'; + +/** Corner of the viewport the launcher bubble is pinned to. */ +export type BubblePosition = 'bottom-right' | 'bottom-left'; + +/** Color scheme for the widget. `auto` follows `prefers-color-scheme`. */ +export type BubbleTheme = 'light' | 'dark' | 'auto'; + +const VALID_POSITIONS: readonly BubblePosition[] = ['bottom-right', 'bottom-left']; +const VALID_THEMES: readonly BubbleTheme[] = ['light', 'dark', 'auto']; +const DEFAULT_POSITION: BubblePosition = 'bottom-right'; +const DEFAULT_THEME: BubbleTheme = 'auto'; + +/** Marker set on a script tag once the loader has mounted from it. */ +const MOUNTED_FLAG = 'rocketrideChatMounted'; + +/** + * Configuration parsed from a loader `']); + + expect(mounted.shadow.querySelector('img')).toBeNull(); + expect(mounted.shadow.querySelector('script')).toBeNull(); + expect((window as Record).__pwned).toBeUndefined(); + expect(getMessages(mounted.shadow, 'assistant')[0].textContent).toContain(' { + const mounted = await createChat(); + await typeAndSubmit(mounted, ''); + // The only svg in the shadow tree is the trusted send-button icon. + const svgs = Array.from(mounted.shadow.querySelectorAll('svg')); + expect(svgs.every((svg) => svg.closest('.rr-send') !== null)).toBe(true); + expect(mounted.shadow.querySelector('svg[onload]')).toBeNull(); + expect((window as Record).__pwned).toBeUndefined(); + await mounted.client.resolveChat(['ok']); + }); +}); + +describe('welcome message', () => { + it('shows the welcome bubble before the first exchange', async () => { + const mounted = await createChat({ welcome: 'Welcome aboard!' }); + const assistant = getMessages(mounted.shadow, 'assistant'); + expect(assistant).toHaveLength(1); + expect(assistant[0].textContent).toContain('Welcome aboard!'); + }); + + it('excludes the welcome bubble from pipeline history', async () => { + const mounted = await createChat({ welcome: 'Welcome aboard!' }); + await typeAndSubmit(mounted, 'real question'); + const payload = JSON.stringify(mounted.client.chatCalls[0].question); + expect(payload).not.toContain('Welcome aboard!'); + await mounted.client.resolveChat(['ok']); + }); +}); + +describe('autoscroll', () => { + function fakeScrollMetrics(el: HTMLElement, metrics: { scrollTop: number; scrollHeight: number; clientHeight: number }): void { + Object.defineProperty(el, 'scrollHeight', { value: metrics.scrollHeight, configurable: true }); + Object.defineProperty(el, 'clientHeight', { value: metrics.clientHeight, configurable: true }); + Object.defineProperty(el, 'scrollTop', { value: metrics.scrollTop, writable: true, configurable: true }); + } + + it('sticks to the bottom for new messages by default', async () => { + const mounted = await createChat(); + const log = mounted.shadow.querySelector('.rr-messages') as HTMLElement; + fakeScrollMetrics(log, { scrollTop: 700, scrollHeight: 1000, clientHeight: 300 }); + log.dispatchEvent(new Event('scroll')); + + await typeAndSubmit(mounted, 'scroll me'); + expect(log.scrollTop).toBe(1000); + await mounted.client.resolveChat(['ok']); + }); + + it('respects a user who scrolled up to read history', async () => { + const mounted = await createChat(); + const log = mounted.shadow.querySelector('.rr-messages') as HTMLElement; + fakeScrollMetrics(log, { scrollTop: 100, scrollHeight: 1000, clientHeight: 300 }); + log.dispatchEvent(new Event('scroll')); // 1000 - 100 - 300 = 600 > threshold + + await typeAndSubmit(mounted, 'do not scroll'); + expect(log.scrollTop).toBe(100); + await mounted.client.resolveChat(['ok']); + }); +}); + +describe('IIFE entry auto-init', () => { + it('registers the element and mounts the bubble from a data-configured script tag', async () => { + const script = document.createElement('script'); + script.setAttribute('data-engine-url', 'http://engine.test:5565'); + script.setAttribute('data-auth', 'TEST-PUBLIC-AUTH-KEY'); + script.setAttribute('data-title', 'Entry Test'); + document.body.appendChild(script); + + Object.defineProperty(document, 'currentScript', { value: script, configurable: true }); + try { + jest.isolateModules(() => { + require('../src/entry-iife'); + }); + } finally { + Object.defineProperty(document, 'currentScript', { value: null, configurable: true }); + } + + expect(customElements.get(WIDGET_TAG)).toBeDefined(); + const host = document.querySelector('[data-rocketride-chat-bubble]'); + expect(host).not.toBeNull(); + const chat = host?.shadowRoot?.querySelector(WIDGET_TAG); + expect(chat?.getAttribute('engine-url')).toBe('http://engine.test:5565'); + + // Detach immediately: the upgraded element would otherwise keep a real + // SDK client retrying against the placeholder URL. + host?.remove(); + await flush(); + }); +}); diff --git a/packages/chat-widget/tests/connection.test.ts b/packages/chat-widget/tests/connection.test.ts new file mode 100644 index 000000000..ac1bfa629 --- /dev/null +++ b/packages/chat-widget/tests/connection.test.ts @@ -0,0 +1,184 @@ +/** + * MIT License + * + * Copyright (c) 2026 Aparavi Software AG + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Headless smoke tests for the UI-free protocol layer (src/connection.ts). + * + * The SDK client is replaced through the injectable ChatClientFactory, so no + * network or engine server is involved. The fake drives the same config + * callbacks (onConnected/onDisconnected/onConnectError) the real + * RocketRideClient would. + */ + +import { PIPELINE_RESULT, Question, QuestionType, RocketRideClientConfig } from 'rocketride'; +import { HISTORY_LIMIT, WidgetConnection, extractAnswerTexts } from '../src/connection'; +import { ChatClientLike, ChatHistoryItem, ConnectionState } from '../src/types'; + +/** Options captured from a chat() call. */ +type ChatOptions = { token: string; question: Question; onSSE?: (type: string, data: Record) => Promise }; + +/** Minimal fake SDK client driving the config callbacks like the real one. */ +class FakeClient implements ChatClientLike { + connected = false; + chatCalls: ChatOptions[] = []; + result: PIPELINE_RESULT = { name: '', path: '', objectId: '' }; + + constructor(private readonly config: RocketRideClientConfig) {} + + async connect(): Promise { + this.connected = true; + await this.config.onConnected?.('connected'); + return {}; + } + + async disconnect(): Promise { + this.connected = false; + await this.config.onDisconnected?.('closed', false); + } + + isConnected(): boolean { + return this.connected; + } + + async chat(options: ChatOptions): Promise { + this.chatCalls.push(options); + await options.onSSE?.('status', { message: 'Searching documents…' }); + await options.onSSE?.('status', { noMessageHere: true }); + return this.result; + } +} + +/** Builds a connection wired to a FakeClient, capturing states and statuses. */ +function makeConnection() { + const states: Array<{ state: ConnectionState; detail?: string }> = []; + const statuses: string[] = []; + let client: FakeClient | null = null; + let config: RocketRideClientConfig | null = null; + + const connection = new WidgetConnection({ + engineUrl: 'http://localhost:5565', + auth: 'PUBLIC-AUTH-KEY-PLACEHOLDER', + onStateChange: (state, detail) => states.push({ state, detail }), + onStatus: (text) => statuses.push(text), + createClient: (clientConfig) => { + config = clientConfig; + client = new FakeClient(clientConfig); + return client; + }, + }); + + return { connection, states, statuses, getClient: () => client, getConfig: () => config }; +} + +describe('WidgetConnection — connect/disconnect lifecycle', () => { + it('configures the SDK with the public auth key only and reports state transitions', async () => { + const { connection, states, getConfig } = makeConnection(); + + expect(connection.state).toBe('idle'); + await connection.connect(); + + const config = getConfig(); + expect(config).not.toBeNull(); + expect(config!.auth).toBe('PUBLIC-AUTH-KEY-PLACEHOLDER'); + expect(config!.uri).toBe('http://localhost:5565'); + expect(config!.persist).toBe(true); + // No ambient env fallback (e.g. ROCKETRIDE_APIKEY) may ever be picked up. + expect(config!.env).toEqual({}); + + expect(states.map((s) => s.state)).toEqual(['connecting', 'connected']); + expect(connection.isConnected()).toBe(true); + + await connection.disconnect(); + // The manual disconnect is not reported as a drop; final state is 'idle'. + expect(states.map((s) => s.state)).toEqual(['connecting', 'connected', 'idle']); + expect(connection.state).toBe('idle'); + }); + + it('rejects a missing configuration and surfaces connection errors', async () => { + const bare = new WidgetConnection({ engineUrl: '', auth: '' }); + await expect(bare.connect()).rejects.toThrow(/engineUrl and auth/); + + const { connection, states, getConfig } = makeConnection(); + await connection.connect(); + // Simulate an SDK-reported drop with error (e.g. HTTP 401 on reconnect). + await getConfig()!.onDisconnected?.('Authentication error', true); + expect(states[states.length - 1]).toEqual({ state: 'error', detail: 'Authentication error' }); + }); +}); + +describe('WidgetConnection — ask()', () => { + it('sends a PROMPT question with capped history and maps SSE statuses to onStatus', async () => { + const { connection, statuses, getClient } = makeConnection(); + await connection.connect(); + + const client = getClient()!; + client.result = { name: '', path: '', objectId: '', result_types: { answers: 'answers' }, answers: ['First answer', 'Second answer'] }; + + const history: ChatHistoryItem[] = Array.from({ length: HISTORY_LIMIT + 2 }, (_item, index) => ({ + role: index % 2 === 0 ? ('user' as const) : ('assistant' as const), + content: `turn ${index}`, + })); + + const answers = await connection.ask('What is RocketRide?', history); + + expect(answers).toEqual(['First answer', 'Second answer']); + expect(client.chatCalls).toHaveLength(1); + const options = client.chatCalls[0]; + // The pipeline is addressed with the same public auth key. + expect(options.token).toBe('PUBLIC-AUTH-KEY-PLACEHOLDER'); + expect(options.question).toBeInstanceOf(Question); + expect(options.question.type).toBe(QuestionType.PROMPT); + expect(options.question.questions).toHaveLength(1); + // History is capped to the most recent HISTORY_LIMIT entries. + expect(options.question.history).toHaveLength(HISTORY_LIMIT); + expect(options.question.history[0].content).toBe('turn 2'); + // Only SSE payloads carrying a message string reach onStatus. + expect(statuses).toEqual(['Searching documents…']); + }); + + it('fails when the connection was never opened', async () => { + const { connection } = makeConnection(); + await expect(connection.ask('hello')).rejects.toThrow(/connect\(\) first/); + }); +}); + +describe('extractAnswerTexts', () => { + it('extracts strings, arrays and { answer } objects from typed fields only', () => { + const result: PIPELINE_RESULT = { + name: '', + path: '', + objectId: '', + result_types: { plain: 'text', list: 'answers', wrapped: 'answers', skipped: 'metadata' }, + plain: 'a plain answer', + list: ['one', ' ', 'two'], + wrapped: { answer: ' wrapped answer ', expectJson: false }, + skipped: 'must not appear', + }; + expect(extractAnswerTexts(result)).toEqual(['a plain answer', 'one', 'two', 'wrapped answer']); + }); + + it('returns an empty list when result_types is missing', () => { + expect(extractAnswerTexts({ name: '', path: '', objectId: '' })).toEqual([]); + }); +}); diff --git a/packages/chat-widget/tests/helpers.ts b/packages/chat-widget/tests/helpers.ts new file mode 100644 index 000000000..4f2b59ffe --- /dev/null +++ b/packages/chat-widget/tests/helpers.ts @@ -0,0 +1,154 @@ +/** + * MIT License + * + * Copyright (c) 2026 Aparavi Software AG + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Shared test helpers for the chat-widget suite. + * + * The suite is developed in parallel with the core component sources + * (src/component.ts, src/render.ts, ...). Helpers here let tests target the + * agreed module contract while degrading to skipped suites (with a visible + * tripwire test) when a core source file has not landed yet. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +/** Returns true when `src/.ts` exists in this package. */ +export function srcExists(name: string): boolean { + return fs.existsSync(path.join(__dirname, '..', 'src', `${name}.ts`)); +} + +/** + * Requires a module, returning `null` (with a warning) when it cannot be + * loaded. Used to keep the suite runnable while sibling sources are still + * landing; the "module availability" tripwire tests fail loudly when a file + * exists on disk but cannot be required. + */ +export function tryRequire(modulePath: string): Record | null { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require(modulePath); + } catch (error) { + console.warn(`[chat-widget tests] could not load ${modulePath}: ${(error as Error).message}`); + return null; + } +} + +/** Picks the first export matching one of the candidate names. */ +export function pickExport(mod: Record | null, names: string[]): T | null { + if (!mod) { + return null; + } + for (const name of names) { + const candidate = (mod as Record)[name]; + if (candidate !== undefined && candidate !== null) { + return candidate as T; + } + } + return null; +} + +/** Flushes pending microtasks and zero-delay timers. */ +export async function flush(rounds = 4): Promise { + for (let i = 0; i < rounds; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +/** Tags the safe renderer is allowed to emit. */ +export const ALLOWED_TAGS = new Set([ + 'p', 'br', 'div', 'span', + 'strong', 'b', 'em', 'i', + 'code', 'pre', + 'a', + 'ul', 'ol', 'li', +]); + +interface ParsedTag { + name: string; + attrs: string; + raw: string; +} + +/** + * Extracts every real HTML tag from a rendered string. Escaped markup + * (`<script>`) contains no raw `<`, so anything matched here is markup + * the renderer actually emitted. + */ +export function collectTags(html: string): ParsedTag[] { + const tags: ParsedTag[] = []; + const tagRe = /<\/?([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g; + let match: RegExpExecArray | null; + while ((match = tagRe.exec(html)) !== null) { + tags.push({ name: match[1].toLowerCase(), attrs: match[2] ?? '', raw: match[0] }); + } + return tags; +} + +/** + * Core XSS invariant for rendered assistant output: + * - only allow-listed tags, + * - no event-handler attributes, + * - no `javascript:` / `data:` / `vbscript:` URLs in any attribute, + * - anchors restricted to http(s) href with the mandated rel/target. + */ +export function assertSafeHtml(html: string): void { + // No raw '<' may survive outside a well-formed emitted tag: strip every + // matched tag and verify nothing tag-like remains. + const withoutTags = html.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g, ''); + expect(withoutTags).not.toMatch(/[<>]/); + + for (const tag of collectTags(html)) { + expect(Array.from(ALLOWED_TAGS)).toContain(tag.name); + expect(tag.attrs).not.toMatch(/\bon[a-zA-Z]+\s*=/); + expect(tag.attrs).not.toMatch(/(javascript|vbscript|data)\s*:/i); + expect(tag.attrs).not.toMatch(/srcdoc|formaction|xlink:href/i); + if (tag.name === 'a' && !tag.raw.startsWith(' carrying the given data-* attributes. */ +function makeScript(data: Record = {}): HTMLScriptElement { + const script = document.createElement('script'); + for (const [key, value] of Object.entries(data)) { + script.setAttribute(`data-${key}`, value); + } + return script; +} + +const BASE_CONFIG: LoaderConfig = { + engineUrl: 'http://localhost:5565', + auth: 'TEST-PUBLIC-AUTH-KEY', + title: 'Test Chat', + accent: '#123456', + position: 'bottom-right', + welcome: 'Hello!', + placeholder: 'Type here...', + theme: 'auto', +}; + +describe('parseLoaderConfig', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('returns null without a script element', () => { + expect(parseLoaderConfig(null)).toBeNull(); + expect(parseLoaderConfig(undefined)).toBeNull(); + }); + + it('returns null when data-engine-url is missing or blank', () => { + expect(parseLoaderConfig(makeScript())).toBeNull(); + expect(parseLoaderConfig(makeScript({ auth: 'TEST-PUBLIC-AUTH-KEY' }))).toBeNull(); + expect(parseLoaderConfig(makeScript({ 'engine-url': '' }))).toBeNull(); + expect(parseLoaderConfig(makeScript({ 'engine-url': ' ' }))).toBeNull(); + }); + + it('applies documented defaults when only data-engine-url is present', () => { + const config = parseLoaderConfig(makeScript({ 'engine-url': 'http://localhost:5565' })); + expect(config).toEqual({ + engineUrl: 'http://localhost:5565', + title: DEFAULT_TITLE, + accent: DEFAULT_ACCENT, + position: 'bottom-right', + theme: 'auto', + }); + }); + + it('parses every documented data-* attribute', () => { + const config = parseLoaderConfig(makeScript({ + 'engine-url': 'http://engine.test:5565', + auth: 'TEST-PUBLIC-AUTH-KEY', + title: 'Support', + accent: '#ff8800', + position: 'bottom-left', + welcome: 'Hi there', + placeholder: 'Ask away', + theme: 'dark', + })); + expect(config).toEqual({ + engineUrl: 'http://engine.test:5565', + auth: 'TEST-PUBLIC-AUTH-KEY', + title: 'Support', + accent: '#ff8800', + position: 'bottom-left', + welcome: 'Hi there', + placeholder: 'Ask away', + theme: 'dark', + }); + }); + + it('trims attribute values', () => { + const config = parseLoaderConfig(makeScript({ + 'engine-url': ' http://localhost:5565 ', + auth: ' TEST-PUBLIC-AUTH-KEY ', + title: ' Padded ', + })); + expect(config?.engineUrl).toBe('http://localhost:5565'); + expect(config?.auth).toBe('TEST-PUBLIC-AUTH-KEY'); + expect(config?.title).toBe('Padded'); + }); + + it('falls back to bottom-right for invalid data-position, with a warning', () => { + const config = parseLoaderConfig(makeScript({ + 'engine-url': 'http://localhost:5565', + position: 'top-center', + })); + expect(config?.position).toBe('bottom-right'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('top-center')); + }); + + it('accepts bottom-left without warning', () => { + const config = parseLoaderConfig(makeScript({ + 'engine-url': 'http://localhost:5565', + position: 'bottom-left', + })); + expect(config?.position).toBe('bottom-left'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('falls back to auto for invalid data-theme, with a warning', () => { + const config = parseLoaderConfig(makeScript({ + 'engine-url': 'http://localhost:5565', + theme: 'sepia', + })); + expect(config?.theme).toBe('auto'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('sepia')); + }); + + it('accepts light and dark themes', () => { + for (const theme of ['light', 'dark'] as const) { + const config = parseLoaderConfig(makeScript({ 'engine-url': 'http://x.test', theme })); + expect(config?.theme).toBe(theme); + } + }); +}); + +describe('mountChatBubble', () => { + const handles: ChatBubbleHandle[] = []; + + function mount(overrides: Partial = {}): ChatBubbleHandle { + const handle = mountChatBubble({ ...BASE_CONFIG, ...overrides }); + handles.push(handle); + return handle; + } + + afterEach(() => { + while (handles.length > 0) { + handles.pop()?.destroy(); + } + document.body.innerHTML = ''; + }); + + it('appends a shadow host to document.body', () => { + const { host } = mount(); + expect(host.isConnected).toBe(true); + expect(host.parentElement).toBe(document.body); + expect(host.shadowRoot).not.toBeNull(); + expect(host.hasAttribute('data-rocketride-chat-bubble')).toBe(true); + }); + + it('renders an accessible launcher button', () => { + const { launcher } = mount(); + expect(launcher.tagName).toBe('BUTTON'); + expect(launcher.type).toBe('button'); + expect(launcher.getAttribute('aria-expanded')).toBe('false'); + expect(launcher.getAttribute('aria-haspopup')).toBe('dialog'); + expect(launcher.getAttribute('aria-label')).toBe('Open chat'); + }); + + it('renders a hidden dialog panel titled from the config', () => { + const { panel } = mount(); + expect(panel.getAttribute('role')).toBe('dialog'); + expect(panel.getAttribute('aria-label')).toBe('Test Chat'); + expect(panel.hidden).toBe(true); + expect(panel.tabIndex).toBe(-1); + }); + + it('hosts a with all config passed through as attributes', () => { + const { chat, panel } = mount(); + expect(chat.tagName.toLowerCase()).toBe(CHAT_TAG); + expect(chat.parentElement).toBe(panel); + expect(chat.getAttribute('engine-url')).toBe('http://localhost:5565'); + expect(chat.getAttribute('auth')).toBe('TEST-PUBLIC-AUTH-KEY'); + expect(chat.getAttribute('title')).toBe('Test Chat'); + expect(chat.getAttribute('accent')).toBe('#123456'); + expect(chat.getAttribute('theme')).toBe('auto'); + expect(chat.getAttribute('welcome')).toBe('Hello!'); + expect(chat.getAttribute('placeholder')).toBe('Type here...'); + }); + + it('omits optional attributes that were not configured', () => { + const { chat } = mount({ auth: undefined, welcome: undefined, placeholder: undefined }); + expect(chat.hasAttribute('auth')).toBe(false); + expect(chat.hasAttribute('welcome')).toBe(false); + expect(chat.hasAttribute('placeholder')).toBe(false); + }); + + it('opens on launcher click: aria-expanded, visible panel, focus moves in', () => { + const handle = mount(); + const panelFocus = jest.spyOn(handle.panel, 'focus'); + + handle.launcher.click(); + + expect(handle.isOpen()).toBe(true); + expect(handle.panel.hidden).toBe(false); + expect(handle.launcher.getAttribute('aria-expanded')).toBe('true'); + expect(handle.launcher.getAttribute('aria-label')).toBe('Close chat'); + expect(panelFocus).toHaveBeenCalled(); + }); + + it('closes on second launcher click and returns focus to the launcher', () => { + const handle = mount(); + const launcherFocus = jest.spyOn(handle.launcher, 'focus'); + + handle.launcher.click(); + handle.launcher.click(); + + expect(handle.isOpen()).toBe(false); + expect(handle.panel.hidden).toBe(true); + expect(handle.launcher.getAttribute('aria-expanded')).toBe('false'); + expect(handle.launcher.getAttribute('aria-label')).toBe('Open chat'); + expect(launcherFocus).toHaveBeenCalled(); + }); + + it('closes on Escape and refocuses the launcher', () => { + const handle = mount(); + const launcherFocus = jest.spyOn(handle.launcher, 'focus'); + + handle.open(); + pressKey(handle.host, 'Escape'); + + expect(handle.isOpen()).toBe(false); + expect(handle.panel.hidden).toBe(true); + expect(launcherFocus).toHaveBeenCalled(); + }); + + it('handles Escape composed from inside the shadow tree (e.g. focus on the launcher)', () => { + const handle = mount(); + handle.open(); + + pressKey(handle.launcher, 'Escape'); + + expect(handle.isOpen()).toBe(false); + }); + + it('ignores Escape while closed and other keys while open', () => { + const handle = mount(); + + pressKey(handle.host, 'Escape'); + expect(handle.isOpen()).toBe(false); + + handle.open(); + pressKey(handle.host, 'Enter'); + pressKey(handle.host, 'a'); + expect(handle.isOpen()).toBe(true); + }); + + it('exposes a programmatic open/close/toggle API', () => { + const handle = mount(); + + expect(handle.isOpen()).toBe(false); + handle.open(); + expect(handle.isOpen()).toBe(true); + handle.open(); // idempotent + expect(handle.isOpen()).toBe(true); + handle.close(); + expect(handle.isOpen()).toBe(false); + handle.close(); // idempotent + expect(handle.isOpen()).toBe(false); + handle.toggle(); + expect(handle.isOpen()).toBe(true); + handle.toggle(); + expect(handle.isOpen()).toBe(false); + }); + + it('destroy() removes the bubble and detaches listeners', () => { + const handle = mount(); + handle.destroy(); + + expect(document.querySelector('[data-rocketride-chat-bubble]')).toBeNull(); + // Click after destroy must be inert (listener removed). + handle.launcher.click(); + expect(handle.isOpen()).toBe(false); + }); +}); + +describe('initFromScript', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('mounts the bubble for a script tag with data-engine-url', () => { + const script = makeScript({ + 'engine-url': 'http://engine.test:5565', + auth: 'TEST-PUBLIC-AUTH-KEY', + title: 'From Script', + }); + document.body.appendChild(script); + + const handle = initFromScript(script); + + expect(handle).not.toBeNull(); + expect(document.querySelector('[data-rocketride-chat-bubble]')).toBe(handle?.host); + expect(handle?.chat.getAttribute('engine-url')).toBe('http://engine.test:5565'); + handle?.destroy(); + }); + + it('is idempotent per script tag', () => { + const script = makeScript({ 'engine-url': 'http://engine.test:5565' }); + document.body.appendChild(script); + + const first = initFromScript(script); + const second = initFromScript(script); + + expect(first).not.toBeNull(); + expect(second).toBeNull(); + expect(document.querySelectorAll('[data-rocketride-chat-bubble]')).toHaveLength(1); + first?.destroy(); + }); + + it('returns null for a script tag without data-engine-url', () => { + const script = makeScript({ title: 'No engine' }); + document.body.appendChild(script); + + expect(initFromScript(script)).toBeNull(); + expect(document.querySelector('[data-rocketride-chat-bubble]')).toBeNull(); + }); + + it('returns null when there is no current script', () => { + expect(initFromScript(null)).toBeNull(); + }); +}); diff --git a/packages/chat-widget/tests/render.test.ts b/packages/chat-widget/tests/render.test.ts new file mode 100644 index 000000000..668cfe33c --- /dev/null +++ b/packages/chat-widget/tests/render.test.ts @@ -0,0 +1,257 @@ +/** + * MIT License + * + * Copyright (c) 2026 Aparavi Software AG + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * XSS suite for the safe assistant-output renderer (src/render.ts). + * + * The renderer receives untrusted model output and must escape ALL HTML + * before applying its minimal formatting (paragraphs, `code`, fenced code, + * **bold**, http(s)-only links). These tests assert the exact safe HTML for + * canonical inputs plus a structural allowlist invariant for adversarial ones. + */ + +import { srcExists, tryRequire, assertSafeHtml } from './helpers'; + +const hasRender = srcExists('render'); +const renderModule = hasRender ? tryRequire('../src/render') : null; + +describe('module availability', () => { + it('loads src/render.ts when present', () => { + if (!hasRender) { + console.warn('[chat-widget tests] src/render.ts not on disk yet — render suite skipped'); + return; + } + expect(renderModule).toBeTruthy(); + expect(typeof (renderModule as Record).renderMessageHtml).toBe('function'); + }); +}); + +const d = renderModule ? describe : describe.skip; + +d('render.ts — safe assistant output renderer', () => { + const render = (text: string): string => + (renderModule as { renderMessageHtml(text: string): string }).renderMessageHtml(text); + + describe('escaping (XSS)', () => { + it('escapes

<script>alert(1)</script>

'); + expect(html).not.toMatch(/\n**nb**\n```'); + expect(html).toBe('
<script>x</script>\n**nb**
'); + expect(html).not.toMatch(//); + assertSafeHtml(html); + }); + + it('renders text around fenced blocks as paragraphs', () => { + const html = render('before\n\n```\ncode\n```\n\nafter'); + expect(html).toBe('

before

code

after

'); + }); + + it('splits paragraphs on blank lines and keeps
for single newlines', () => { + expect(render('first\n\nsecond line1\nline2')).toBe('

first

second line1
line2

'); + }); + + it('normalizes CRLF line endings', () => { + expect(render('a\r\n\r\nb')).toBe('

a

b

'); + }); + + it('returns an empty string for empty/whitespace input', () => { + expect(render('')).toBe(''); + expect(render(' ')).toBe(''); + }); + }); + + describe('internal token sentinel cannot be forged', () => { + it('strips NUL characters from input', () => { + expect(render('a\u00000\u0000b')).toBe('

a0b

'); + }); + + it('NUL-wrapped indices in input cannot address the stash', () => { + const html = render('`x` \u00000\u0000'); + expect(html).toBe('

x 0

'); + assertSafeHtml(html); + }); + + it('restores nested tokens: inline code inside a markdown link label', () => { + const html = render('[`x`](https://a.com)'); + expect(html).toBe( + '

x

' + ); + expect(html.includes(String.fromCharCode(0))).toBe(false); + assertSafeHtml(html); + }); + + it('leaves no sentinel behind when a bare URL absorbs a stashed token', () => { + const html = render('the [`useState`](https://docs.example/foo) hook, see https://a.com/`x` too'); + expect(html).toContain('useState'); + expect(html.includes(String.fromCharCode(0))).toBe(false); + assertSafeHtml(html); + }); + }); + + describe('adversarial corpus invariant', () => { + const corpus = [ + 'ipt>alert(1)ipt>', + '">', + '``', + '** ** ** **', + '[a](https://x.test) [b](javascript:y) `z`', + '```\n``\n```', + '[![img](https://x.test/i.png)](https://x.test)', + 'https://example.com/**bold**', + 'x', + '<script>alert(1)</script>', + ]; + + it.each(corpus)('emits only allow-listed, handler-free markup for %s', (payload) => { + assertSafeHtml(render(payload)); + }); + }); +}); diff --git a/packages/chat-widget/tests/theming.test.ts b/packages/chat-widget/tests/theming.test.ts new file mode 100644 index 000000000..1a3a38dbb --- /dev/null +++ b/packages/chat-widget/tests/theming.test.ts @@ -0,0 +1,191 @@ +/** + * MIT License + * + * Copyright (c) 2026 Aparavi Software AG + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Theming tests: accent -> CSS custom property, launcher/bubble position, + * and the component's light/dark/auto (prefers-color-scheme) resolution. + */ + +import { RocketRideChatElement, WIDGET_TAG } from '../src/component'; +import '../src/index'; // registers the custom element (guarded) +import { ChatBubbleHandle, DEFAULT_ACCENT, mountChatBubble, LoaderConfig } from '../src/loader'; +import { flush } from './helpers'; + +const BASE_CONFIG: LoaderConfig = { + engineUrl: 'http://localhost:5565', + title: 'Theming', + accent: DEFAULT_ACCENT, + position: 'bottom-right', + theme: 'auto', +}; + +/** Stubs window.matchMedia with a fixed prefers-color-scheme answer. */ +function stubMatchMedia(prefersDark: boolean): jest.Mock { + const mock = jest.fn().mockImplementation((query: string) => ({ + matches: prefersDark && query.includes('prefers-color-scheme: dark'), + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + Object.defineProperty(window, 'matchMedia', { value: mock, writable: true, configurable: true }); + return mock; +} + +function clearMatchMedia(): void { + delete (window as Record).matchMedia; +} + +describe('loader bubble theming', () => { + const handles: ChatBubbleHandle[] = []; + + function mount(overrides: Partial = {}): ChatBubbleHandle { + const handle = mountChatBubble({ ...BASE_CONFIG, ...overrides }); + handles.push(handle); + return handle; + } + + afterEach(() => { + while (handles.length > 0) { + handles.pop()?.destroy(); + } + document.body.innerHTML = ''; + }); + + it('sets --rr-accent on the bubble host (default RocketRide violet)', () => { + const { host } = mount(); + expect(host.style.getPropertyValue('--rr-accent')).toBe('#5f2167'); + }); + + it('propagates a custom accent to the host property and the chat element', () => { + const { host, chat } = mount({ accent: '#0a84ff' }); + expect(host.style.getPropertyValue('--rr-accent')).toBe('#0a84ff'); + expect(chat.getAttribute('accent')).toBe('#0a84ff'); + }); + + it('styles the launcher through the accent custom property', () => { + const { host } = mount(); + const css = host.shadowRoot?.querySelector('style')?.textContent ?? ''; + expect(css).toContain('var(--rr-accent'); + }); + + it('positions bottom-right by default and bottom-left on request', () => { + const right = mount(); + const rightWrapper = right.host.shadowRoot?.querySelector('.rr-bubble'); + expect(rightWrapper?.classList.contains('rr-pos-bottom-right')).toBe(true); + + const left = mount({ position: 'bottom-left' }); + const leftWrapper = left.host.shadowRoot?.querySelector('.rr-bubble'); + expect(leftWrapper?.classList.contains('rr-pos-bottom-left')).toBe(true); + expect(leftWrapper?.classList.contains('rr-pos-bottom-right')).toBe(false); + }); + + it('forwards the theme setting to the chat element', () => { + for (const theme of ['light', 'dark', 'auto'] as const) { + const { chat } = mount({ theme }); + expect(chat.getAttribute('theme')).toBe(theme); + } + }); +}); + +describe('component theming', () => { + async function createChat(attrs: Record = {}): Promise { + const el = document.createElement(WIDGET_TAG) as RocketRideChatElement; + // No engine-url/auth: theming works without any connection. + for (const [name, value] of Object.entries(attrs)) { + el.setAttribute(name, value); + } + document.body.appendChild(el); + await flush(1); + return el; + } + + function getRoot(el: RocketRideChatElement): HTMLElement { + return el.shadowRoot?.querySelector('.rr-root') as HTMLElement; + } + + afterEach(() => { + document.body.innerHTML = ''; + clearMatchMedia(); + }); + + it('maps the accent attribute to the --rr-accent custom property', async () => { + const el = await createChat({ accent: '#ff8800' }); + expect(getRoot(el).style.getPropertyValue('--rr-accent')).toBe('#ff8800'); + }); + + it('falls back to the stylesheet default accent when the attribute is absent', async () => { + const el = await createChat(); + expect(getRoot(el).style.getPropertyValue('--rr-accent')).toBe(''); + const css = el.shadowRoot?.querySelector('style')?.textContent ?? ''; + expect(css).toContain('#5f2167'); + }); + + it('updates the accent live on attribute change', async () => { + const el = await createChat({ accent: '#ff8800' }); + el.setAttribute('accent', '#123123'); + expect(getRoot(el).style.getPropertyValue('--rr-accent')).toBe('#123123'); + }); + + it('applies explicit light and dark themes', async () => { + const light = await createChat({ theme: 'light' }); + expect(getRoot(light).getAttribute('data-theme')).toBe('light'); + + const dark = await createChat({ theme: 'dark' }); + expect(getRoot(dark).getAttribute('data-theme')).toBe('dark'); + }); + + it('resolves auto theme from prefers-color-scheme', async () => { + stubMatchMedia(true); + const dark = await createChat({ theme: 'auto' }); + expect(getRoot(dark).getAttribute('data-theme')).toBe('dark'); + document.body.innerHTML = ''; + + stubMatchMedia(false); + const light = await createChat({ theme: 'auto' }); + expect(getRoot(light).getAttribute('data-theme')).toBe('light'); + }); + + it('defaults to auto when the theme attribute is missing or invalid', async () => { + stubMatchMedia(true); + const missing = await createChat(); + expect(getRoot(missing).getAttribute('data-theme')).toBe('dark'); + document.body.innerHTML = ''; + + const invalid = await createChat({ theme: 'sepia' }); + expect(getRoot(invalid).getAttribute('data-theme')).toBe('dark'); + }); + + it('switches theme live on attribute change', async () => { + const el = await createChat({ theme: 'light' }); + el.setAttribute('theme', 'dark'); + expect(getRoot(el).getAttribute('data-theme')).toBe('dark'); + }); + + it('ships light and dark palettes in the shadow stylesheet', async () => { + const el = await createChat(); + const css = el.shadowRoot?.querySelector('style')?.textContent ?? ''; + expect(css).toContain('data-theme'); + expect(css).toContain('--rr-accent'); + }); +}); diff --git a/packages/chat-widget/tsconfig.json b/packages/chat-widget/tsconfig.json new file mode 100644 index 000000000..a1604919d --- /dev/null +++ b/packages/chat-widget/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "baseUrl": ".", + "paths": { + "rocketride": ["../client-typescript/src/client/index.ts"] + } + }, + "include": ["./src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/chat-widget/tsconfig.test.json b/packages/chat-widget/tsconfig.test.json new file mode 100644 index 000000000..ab90a9c27 --- /dev/null +++ b/packages/chat-widget/tsconfig.test.json @@ -0,0 +1,25 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "noUnusedLocals": false, + "types": [ + "jest", + "node" + ], + "baseUrl": ".", + "paths": { + "rocketride": [ + "../client-typescript/src/client/index.ts" + ] + } + }, + "include": [ + "./src/**/*", + "./tests/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/chat-widget/tsconfig.types.json b/packages/chat-widget/tsconfig.types.json new file mode 100644 index 000000000..baf6659a2 --- /dev/null +++ b/packages/chat-widget/tsconfig.types.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": {}, + "rootDir": "./src", + "outDir": "./dist/types", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d526f554..54ee3e856 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -706,6 +706,43 @@ importers: specifier: ^5.3.0 version: 5.9.3 + packages/chat-widget: + dependencies: + rocketride: + specifier: workspace:* + version: link:../client-typescript + devDependencies: + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^20.19.41 + version: 20.19.41 + esbuild: + specifier: '>=0.28.1 <1' + version: 0.28.1 + jest: + specifier: ^29.0.0 + version: 29.7.0(@types/node@20.19.41)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 + jest-jasmine2: + specifier: ^30.4.2 + version: 30.4.2 + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + ts-jest: + specifier: ^29.4.10 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + packages/client-typescript: dependencies: commander: @@ -738,7 +775,7 @@ importers: version: 5.0.10 ts-jest: specifier: ^29.4.10 - version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.0.0 version: 5.9.3 @@ -4499,6 +4536,10 @@ packages: '@textlint/types@15.7.1': resolution: {integrity: sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==} + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} + engines: {node: '>= 10'} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -4712,6 +4753,9 @@ packages: '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -4836,6 +4880,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -5163,10 +5210,17 @@ packages: '@xyflow/system@0.0.47': resolution: {integrity: sha512-aUXJPIvsCFxGX70ccRG8LPsR+A8ExYXfh/noYNpqn8udKerrLdSHxMG2VsvUrQ1PGex10fOpbJwFU4A+I/Xv8w==} + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -5208,6 +5262,10 @@ packages: resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} engines: {node: '>=12.0'} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -6158,6 +6216,16 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -6291,6 +6359,10 @@ packages: dagre@0.8.5: resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -6334,6 +6406,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -6490,6 +6565,11 @@ packages: domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + domhandler@4.3.1: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} @@ -6683,6 +6763,11 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + eslint-config-prettier@9.1.2: resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} hasBin: true @@ -6740,6 +6825,11 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -7325,6 +7415,10 @@ packages: html-dom-parser@5.1.4: resolution: {integrity: sha512-UGjp7y8jSrDU2RdN2J9FDOo3X+WBu+LkS/I3TjjFW020ocZWjPvave+RKk1zeuY2sUWy5fh6zHgXHthzDGlFNQ==} + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -7398,6 +7492,10 @@ packages: http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -7419,6 +7517,10 @@ packages: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -7748,6 +7850,9 @@ packages: resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -7941,6 +8046,15 @@ packages: resolution: {integrity: sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + jest-environment-node@29.7.0: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8123,6 +8237,15 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -8986,6 +9109,9 @@ packages: peerDependencies: webpack: ^4.0.0 || ^5.0.0 + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -10009,6 +10135,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -10041,6 +10170,9 @@ packages: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -10708,6 +10840,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -11221,6 +11357,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + sync-child-process@1.0.2: resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==} engines: {node: '>=16.0.0'} @@ -11388,9 +11527,17 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + tree-changes@0.11.3: resolution: {integrity: sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag==} @@ -11647,6 +11794,10 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -11688,6 +11839,9 @@ packages: file-loader: optional: true + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-composed-ref@1.4.0: resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} peerDependencies: @@ -11799,6 +11953,10 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -11821,6 +11979,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-bundle-analyzer@4.10.2: resolution: {integrity: sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==} engines: {node: '>= 10.13.0'} @@ -11896,15 +12058,28 @@ packages: resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} engines: {node: '>=0.8.0'} + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -12013,6 +12188,10 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + xml2js@0.5.0: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} engines: {node: '>=4.0.0'} @@ -12021,6 +12200,9 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -17299,6 +17481,8 @@ snapshots: dependencies: '@textlint/ast-node-types': 15.7.1 + '@tootallnate/once@2.0.1': {} + '@tsconfig/node10@1.0.12': optional: true @@ -17577,6 +17761,12 @@ snapshots: expect: 30.2.0 pretty-format: 30.2.0 + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 20.19.41 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + '@types/json-schema@7.0.15': {} '@types/mdast@4.0.4': @@ -17716,6 +17906,8 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': optional: true @@ -18106,11 +18298,18 @@ snapshots: d3-selection: 3.0.0 d3-zoom: 3.0.0 + abab@2.0.6: {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 negotiator: 0.6.3 + acorn-globals@7.0.1: + dependencies: + acorn: 8.16.0 + acorn-walk: 8.3.5 + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -18135,6 +18334,12 @@ snapshots: adm-zip@0.5.17: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} aggregate-error@3.1.0: @@ -19257,6 +19462,14 @@ snapshots: dependencies: css-tree: 2.2.1 + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + csstype@3.2.3: {} d3-array@3.2.4: @@ -19416,6 +19629,12 @@ snapshots: graphlib: 2.1.8 lodash: 4.18.1 + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -19454,6 +19673,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -19601,6 +19822,10 @@ snapshots: domelementtype@2.3.0: {} + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + domhandler@4.3.1: dependencies: domelementtype: 2.3.0 @@ -19873,6 +20098,14 @@ snapshots: escape-string-regexp@5.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -19970,6 +20203,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -20729,6 +20964,10 @@ snapshots: domhandler: 5.0.3 htmlparser2: 10.1.0 + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + html-escaper@2.0.2: {} html-minifier-terser@6.1.0: @@ -20824,6 +21063,14 @@ snapshots: http-parser-js@0.5.10: {} + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.1 + agent-base: 6.0.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -20856,6 +21103,13 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -21117,6 +21371,8 @@ snapshots: dependencies: isobject: 3.0.1 + is-potential-custom-element-name@1.0.1: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.9 @@ -21383,6 +21639,21 @@ snapshots: jest-util: 30.4.1 pretty-format: 30.4.1 + jest-environment-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 20.19.41 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jest-environment-node@29.7.0: dependencies: '@jest/environment': 29.7.0 @@ -21815,6 +22086,39 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.16.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.6 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.21.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -22964,6 +23268,8 @@ snapshots: schema-utils: 3.3.0 webpack: 5.104.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15) + nwsapi@2.2.24: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -24042,6 +24348,10 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -24069,6 +24379,8 @@ snapshots: dependencies: side-channel: 1.1.0 + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -24898,6 +25210,10 @@ snapshots: sax@1.6.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -25476,6 +25792,8 @@ snapshots: picocolors: 1.1.1 sax: 1.6.0 + symbol-tree@3.2.4: {} + sync-child-process@1.0.2: dependencies: sync-message-port: 1.2.0 @@ -25649,8 +25967,19 @@ snapshots: totalist@3.0.1: {} + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tr46@0.0.3: {} + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + tree-changes@0.11.3: dependencies: '@gilbarbara/deep-equal': 0.3.1 @@ -25689,7 +26018,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -25707,6 +26036,7 @@ snapshots: '@jest/transform': 30.4.1 '@jest/types': 30.4.1 babel-jest: 29.7.0(@babel/core@7.29.7) + esbuild: 0.28.1 jest-util: 30.4.1 ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3): @@ -25925,6 +26255,8 @@ snapshots: universal-user-agent@7.0.3: {} + universalify@0.2.0: {} + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -25994,6 +26326,11 @@ snapshots: optionalDependencies: file-loader: 6.2.0(webpack@5.104.1(postcss@8.5.15)) + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + use-composed-ref@1.4.0(@types/react@18.3.29)(react@18.3.1): dependencies: react: 18.3.1 @@ -26103,6 +26440,10 @@ snapshots: void-elements@3.1.0: {} + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -26124,6 +26465,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + webpack-bundle-analyzer@4.10.2: dependencies: '@discoveryjs/json-ext': 0.5.7 @@ -26320,12 +26663,23 @@ snapshots: websocket-extensions@0.1.4: {} + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -26455,6 +26809,8 @@ snapshots: dependencies: sax: 1.6.0 + xml-name-validator@4.0.0: {} + xml2js@0.5.0: dependencies: sax: 1.6.0 @@ -26462,6 +26818,8 @@ snapshots: xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fdd45ad48..30ea387ea 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,6 +26,7 @@ packages: - 'packages/shared-ui' # Client libraries + - 'packages/chat-widget' - 'packages/client-typescript' # Documentation site From 06d8e572b222371e04558f5467ce6f0260d2e077 Mon Sep 17 00:00:00 2001 From: nihalnihalani <24360630+nihalnihalani@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:45:57 +0530 Subject: [PATCH 2/3] docs: chat widget guide and clients-index entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embedding guide for both consumption modes with copy-paste snippets, the full attribute / CSS-custom-property / events reference, and a security section: authenticate with the pipeline's public key only — never the engine API key or private token — plus CORS/TLS notes for exposing an engine to browsers. Co-Authored-By: Claude Fable 5 --- docs/README-chat-widget.md | 318 +++++++++++++++++++++++++++++++++++++ docs/README-clients.md | 26 +-- 2 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 docs/README-chat-widget.md diff --git a/docs/README-chat-widget.md b/docs/README-chat-widget.md new file mode 100644 index 000000000..89722bd27 --- /dev/null +++ b/docs/README-chat-widget.md @@ -0,0 +1,318 @@ +

RocketRide Chat Widget

+ +

+ Embed a brandable AI chat on any web page — one script tag, zero frameworks. +

+ +

+ npm + GitHub + Discord + MIT License +

+ +`rocketride-chat-widget` is a framework-free chat UI for [RocketRide](https://rocketride.org) pipelines. One lean browser bundle (no React, no CSS frameworks) gives you two consumption modes: + +- **Floating chat bubble** — a single ` +``` + +That is the whole integration: a launcher bubble appears in the chosen corner, clicking it opens the chat panel, and Escape closes it again. The loader reads its configuration from the script tag's own `data-*` attributes. + +> Use a **classic** script tag (`defer` is fine). With `type="module"` the browser leaves `document.currentScript` unset, so the loader cannot find its configuration and no bubble is mounted. Pin a version for production, e.g. `https://unpkg.com/rocketride-chat-widget@1.3.0/dist/rocketride-chat.js`. + +### Option 2 — inline web component + +The same bundle also registers the `` custom element (with a plain script tag, just omit `data-engine-url` if you don't want the bubble too): + +```html + + +
+ + +
+``` + +The element fills its container (and keeps a 320px minimum height), so give it a sized parent. + +With a bundler, install the package and import it once — the import registers the element: + +```bash +# NPM +npm install rocketride-chat-widget +# Yarn +yarn add rocketride-chat-widget +# PNPM +pnpm add rocketride-chat-widget +``` + +```typescript +import 'rocketride-chat-widget'; // registers +``` + +Don't have a pipeline yet? Visit [RocketRide on GitHub](https://github.com/rocketride-org/rocketride-server) or download the extension directly in your IDE. + +## What is RocketRide? + +[RocketRide](https://rocketride.org) is an open source, developer-native AI pipeline platform. +It lets you build, debug, and deploy production AI workflows without leaving your IDE - +using a visual drag-and-drop canvas or code-first with TypeScript and Python SDKs. + +- **50+ ready-to-use nodes** - 13 LLM providers, 8 vector databases, OCR, NER, PII anonymization, and more +- **High-performance C++ engine** - production-grade speed and reliability +- **Deploy anywhere** - locally, on-premises, or self-hosted with Docker +- **MIT licensed** - fully open source, OSI-compliant + +You build your `.pipe` - and the widget puts a chat UI on it, anywhere on the web. + +## Features + +- **Two modes, one bundle** - inline `` web component and script-tag launcher bubble +- **Framework-free** - vanilla TypeScript web component; no React, no runtime dependencies +- **Style isolation** - shadow DOM keeps host CSS out and widget CSS in +- **Brandable** - accent color, title, welcome text, placeholder via attributes; full theming via CSS custom properties +- **Light / dark / auto** - `auto` follows `prefers-color-scheme` and updates live +- **Live status** - connecting / online / offline states, a "thinking" line with the pipeline's live status while it works, and an error banner with a Retry button +- **Safe output rendering** - escape-first formatter for assistant text (paragraphs, code, bold, http(s) links only); raw model output is never injected as HTML +- **Accessible** - `role="log"` with `aria-live="polite"`, labeled controls, keyboard support, focus management, reduced-motion support +- **Public-key auth only** - designed so no private credential ever ships to the browser + +--- + +## Security: public auth key only + +**The widget must only ever be configured with a pipeline's PUBLIC Authorization Key.** + +- **Where to find it:** when a pipeline with a chat source node is running, the node publishes a link of the form `{host}/chat?auth={public_auth}` along with the key itself (labeled _Public Authorization Key_, prefixed `pk_`). That `pk_…` value is what goes into the widget's `auth` / `data-auth`. +- **What it grants:** the public key is scoped to that one running pipeline's chat interface. It both authenticates the connection and addresses the pipeline — the widget needs nothing else. +- **What must never appear in a page:** the RocketRide engine API key (`ROCKETRIDE_APIKEY`) or any private task token (`tk_…`). Anything in an HTML attribute is readable by every visitor via _View Source_. The widget also never falls back to ambient environment credentials — the only credential it will ever send is the one you set explicitly. +- **Treat it like a public endpoint:** anyone with the page (and therefore the key) can chat with that pipeline. Restarting the pipeline issues a new public key, so an old key can be retired by republishing. Apply the same rate limiting / abuse protection you would give any public form. + +### Exposing an engine to browsers (CORS and TLS) + +- **CORS.** By default the engine's web endpoints accept requests from any `localhost` / `127.0.0.1` origin (any port) — enough for local development. To embed the widget on a real site, set the `RR_CORS_ORIGINS` environment variable on the engine to a comma-separated list of allowed origins (e.g. `RR_CORS_ORIGINS=https://www.example.com`). +- **TLS / mixed content.** Browsers block insecure connections from `https` pages. If the embedding page is served over `https`, the `engine-url` must be `https` too (the SDK upgrades it to a secure WebSocket automatically) — in practice, put the engine behind a TLS-terminating reverse proxy and use that URL. +- **Don't expose more than you need.** The page only needs to reach the engine's chat endpoint; keep engine management interfaces off the public network. + +--- + +## `` attributes + +All attributes are observed — changing them on a live element takes effect immediately. Changing `engine-url` or `auth` tears down the connection and reconnects. + +| Attribute | Required | Default | Description | +| ------------- | -------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `engine-url` | Yes | - | RocketRide engine URL, e.g. `http://localhost:5565`. `http(s)` or `ws(s)` accepted; converted to WebSocket internally. | +| `auth` | Yes | - | The pipeline's **PUBLIC** Authorization Key (`pk_…`). Never an engine API key or private token — see [Security](#security-public-auth-key-only). | +| `title` | No | `RocketRide Assistant` | Header title. Note: `title` is also a global HTML attribute, so browsers additionally show it as a hover tooltip on the element. | +| `accent` | No | `#5f2167` (RocketRide violet) | Brand accent color; any CSS color value. Shorthand for setting `--rr-accent`. | +| `welcome` | No | (none) | Assistant-styled welcome bubble shown before the first exchange. Not sent to the pipeline as history. | +| `placeholder` | No | `Type a message…` | Input placeholder text. | +| `theme` | No | `auto` | `light` \| `dark` \| `auto`. `auto` follows `prefers-color-scheme` and updates live when the OS theme changes. | + +The element connects when both `engine-url` and `auth` are present and it is attached to the document. Until then it renders in the idle/offline state. + +## Script-tag loader (`data-*` attributes) + +The IIFE bundle auto-initializes the bubble when its own `