npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@nexlylab/widget-react

v3.0.1

Published

React shell for the Studio widget — asks the server which runtime this API key gets and loads it from the CDN, so a host never rebuilds for a widget release

Readme

@nexlylab/widget-react

Drop-in React component for embedding an AI-powered Studio widget into any website. A single <StudioWidget /> gives your users a branded floating panel with configurable content blocks, real-time AI chat, support chat, multi-language UI, autologin, and more — all configured from the Studio admin panel without code deploys.

StudioWidget is the package's only public component. It is a ~2.3 KB (gzipped) shell: the runtime version is resolved server-side per API key and loaded from the CDN, so a widget release reaches your users without you rebuilding. The bundled component that used to live here, WidgetCore, is no longer part of this package at all: as of 3.0.0 it is an internal package of the Studio monorepo, compiled into the CDN payload this shell loads. Migrating from it is the import line and nothing else; see How the default entry works.

Table of Contents


Installation

npm install @nexlylab/widget-react
# or
pnpm add @nexlylab/widget-react

Peer dependencies: react and react-dom (17+), axios (1.0+)

Styles are auto-injected when the component loads — no separate CSS import needed.


Quick Start

import { StudioWidget } from '@nexlylab/widget-react';

function App() {
  return (
    <StudioWidget
      apiKey="pk_live_your_key"
      serverUrl="https://your-studio-server.com"
    />
  );
}

That's all that's required. The widget renders a floating button in the bottom-right corner. Clicking it opens the panel with all views configured in the admin panel.


How the default entry works — evergreen, no rebuild per release

<StudioWidget> is a ~2.3 KB (gzipped) shell. It ships no UI of its own: it asks the server which runtime version your key should get, loads that from the CDN, and hands it your props. The version your users get is therefore a server-side setting, not something frozen into your build at npm install time.

Two props are worth adding to the Quick Start above:

import { StudioWidget } from '@nexlylab/widget-react';

function App() {
  return (
    <StudioWidget
      apiKey="pk_live_your_key"
      serverUrl="https://your-studio-server.com"
      onError={(error) => Sentry.captureException(error)}
      fallback={null}
    />
  );
}

onError also receives load failures (see below), and fallback is what renders if the runtime cannot be loaded at all — it defaults to nothing, so a page that could not load the widget looks like a page without one rather than a page with a broken one.

If you previously used the bundled <WidgetCore>, the migration is the import line and nothing else: every prop <WidgetCore> accepts, <StudioWidget> accepts and forwards untouched — including props added after you built. That is the point of the shell: once it is in your bundle, later widget releases reach your users without you rebuilding anything.

What changes for you

| | before (bundled WidgetCore) | now (StudioWidget, the default entry) | |---|---|---| | Widget version | pinned at build time | resolved per request, server-side | | Your bundle | the whole widget | ~2.3 KB gzipped | | React | yours (17/18/19) | its own, isolated — yours is untouched | | Needs CSP script-src for our CDN | no | yes | | Errors reach onError | yes | yes — plus load failures, see below |

The bundled component is not published any more. Up to 2.x it was reachable as @nexlylab/widget-react/core; 3.0.0 removes that subpath, because importing it put the whole widget back into your bundle and gave up the server-side version control this package exists to provide. It now lives inside the CDN payload only.

The CSP entry is the one prerequisite. The shell imports two modules with dynamic import(), which inherits your page's nonce — so no connect-src change is needed — but both origins must be in your script-src: the Studio server that serves the version pointer, and the CDN that serves the runtime it names. The CDN entry can be scoped to our packages rather than the whole CDN — a path-prefix source such as https://cdn.jsdelivr.net/npm/@nexlylab/ (trailing slash) allows every runtime the pointer can name and nothing else served from there. Ask us for the exact values for your account before switching; there is no way for the shell to work around a policy that blocks either of them.

Error handling. onError also receives load failures (pointer_unreachable, pointer_malformed, key_unknown, runtime_unreachable, runtime_incompatible). Narrow them with isWidgetRemoteError:

import { isWidgetRemoteError } from '@nexlylab/widget-react';

const onError = (error: Error) => {
  if (isWidgetRemoteError(error) && error.code === 'key_unknown') return; // retired key
  Sentry.captureException(error);
};

The shell keeps a React error boundary around itself and the runtime keeps one around the widget, so a crash inside the widget takes down neither your page nor your own boundaries' other coverage. When the runtime cannot load at all, fallback is rendered — by default nothing, so the page looks like a page without the widget.

Hosts that cannot load code from an external origin should stay on the default entry; it is not going away.


What's in the box

  • Configurable content blocks — all tabs and blocks are managed from the admin panel (no redeploys)
  • Real-time AI chat via Socket.io — streaming responses, session history, notifications
  • Support chat — dedicated support sessions with agent escalation
  • Three auth modes — anonymous guest, endUserApiKey autologin, HMAC SSO
  • Light / dark / system theme with live OS detection and host-page-class detection (Tailwind, shadcn, daisyUI)
  • Multi-language UI — translations served from admin panel, user preference persisted
  • Unread badge on the floating button — real-time count via socket
  • User context injection — pass user identity metadata to every AI response
  • Fullscreen toggle and keyboard accessibility (Esc, tab navigation)
  • Build version in footer — WidgetFooter displays the current package version (e.g. v0.3.13) injected at build time from package.json
  • Tree-shakeable ESM + CJS dual build
  • Auto-injected CSS — no manual stylesheet import

Props Reference

All props are optional except apiKey and serverUrl.

| Prop | Type | Default | Description | |------|------|---------|-------------| | apiKey | string | required | Public product API key (pk_live_xxx or pk_test_xxx). Never use a secret key here. | | serverUrl | string | required | Backend base URL for REST API and Socket.io — and the origin the shell asks for your runtime version, which is why it is required here. | | fallback | ReactNode | null | Rendered if the runtime cannot be loaded at all. Shell only. | | importModule | (url: string) => Promise<unknown> | — | Override how the shell imports modules, for bundlers that mangle the dynamic import() magic comments. Shell only. | | theme | 'light' \| 'dark' \| 'system' | 'system' | Color theme. 'system' detects the host page theme and OS preference and updates live. | | position | 'bottom-right' \| 'bottom-left' | 'bottom-right' | Position of the floating launch button. | | brandName | string | 'Studio' | Name displayed in the widget header. | | initialOpen | boolean | false | Whether the panel should open immediately on mount. | | customStyles | WidgetCustomStyles | — | CSS custom property overrides (only --prefixed keys are applied). See Theming. | | userContext | UserContext | — | User identity metadata forwarded to every AI call. See User Context. | | endUserApiKey | string | — | Per-user end-user API key (eu_live_xxx). Widget auto-logs in on mount and re-authenticates when the key changes. See Autologin via endUserApiKey. | | signature | string | — | HMAC-signed SSO token. Exchanged for a widget session at /api/widget/auth/sso. See HMAC SSO. | | onReady | () => void | — | Fired once when the widget has validated the API key and is ready to render. | | onError | (error: Error) => void | — | Fired on API key validation failure or endUserApiKey autologin failure. Widget continues in guest mode. | | onConnectionChange | (connected: boolean) => void | — | Fired when the Socket.io connection state changes. Only fires when serverUrl is set. | | onUnreadCountChange | (count: number) => void | — | Fired when the unread message badge count changes. | | onMessage | (msg: ChatMessagePayload) => void | — | Fired when a new AI chat message arrives via socket. | | onNotification | (n: ChatNotificationPayload) => void | — | Fired when a chat notification arrives via socket. | | onLogin | (user: WidgetUser) => void | — | Fired when a user successfully logs in (via form, endUserApiKey, or SSO). | | onLogout | () => void | — | Fired when the user logs out. |

UserContext shape

type UserContext = {
  userUuid: string;        // required — your app's user ID
  name?: string;           // displayed in widget header after login
  language?: string;       // preferred language code (e.g. 'en', 'ko')
  metadata?: Record<string, unknown>; // arbitrary key/value forwarded to AI context
};

userUuid is used to scope socket room subscriptions and validate user identity. Pass it whenever you have a logged-in user on the host page.


Authentication

The widget supports three auth modes. They are not mutually exclusive — endUserApiKey and signature provide automatic login while the widget can always fall back to guest mode on failure.

1. Anonymous (guest) mode

Pass just apiKey and serverUrl, with no auth prop. The widget loads in guest mode: chat is disabled for unauthenticated routes, but any publicly configured blocks are shown. Users can log in via the in-widget auth form if the auth block is enabled.

<StudioWidget
  apiKey="pk_live_xxx"
  serverUrl="https://your-studio-server.com"
/>

No backend provisioning required. The product API key is a public key — safe to ship in the browser.


2. Autologin via endUserApiKey (v1)

Pre-provision a per-user key from your backend and pass it as endUserApiKey. The widget calls POST /api/widget/auth/api-key on mount, gets a widget JWT, and logs the user in — no login form shown.

Use this when: your backend already issues per-user records and you want zero-friction login from any page.

How it works

Your backend provisions eu_live_xxx  →  frontend passes it as endUserApiKey  →  widget POSTs to /api/widget/auth/api-key  →  widget receives access + refresh tokens
  1. Your backend calls POST /api/widget/auth/register once per user (idempotent by externalId, but returns the API key only on the first call — persist it yourself).
  2. Your frontend reads the key from your backend and passes it as endUserApiKey.
  3. If the key changes (user switches accounts), the widget re-authenticates automatically.
  4. If autologin fails, onError fires and the widget falls back to guest mode.

Step 1 — Provision the end-user key (your backend)

POST https://<studio-server>/api/widget/auth/register
X-API-Key: pk_live_xxx
Origin: https://your-site.com
Content-Type: application/json

{
  "externalId": "user-123",
  "email": "[email protected]",
  "name": "Jane Doe",
  "authMethod": "api_key"
}

First-call response (save apiKey — it is only returned once):

{
  "success": true,
  "data": {
    "id": "uuid",
    "apiKey": "eu_live_xxx…",
    "externalId": "user-123"
  }
}

Node.js / Next.js API route:

// app/api/widget-user-key/route.ts
import { getServerSession } from 'next-auth';

export async function GET() {
  const session = await getServerSession();
  if (!session?.user?.id) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  // Return cached key if already provisioned
  const cached = await db.widgetKeys.findByUserId(session.user.id);
  if (cached) return Response.json({ apiKey: cached.key });

  // First time — provision from Studio
  const res = await fetch(`${process.env.STUDIO_URL}/api/widget/auth/register`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': process.env.WIDGET_PRODUCT_API_KEY!,
      'Origin': process.env.SITE_ORIGIN!,
    },
    body: JSON.stringify({
      externalId: session.user.id,
      email: session.user.email,
      name: session.user.name,
      authMethod: 'api_key',
    }),
  });

  const { data } = await res.json();
  await db.widgetKeys.save({ userId: session.user.id, key: data.apiKey });
  return Response.json({ apiKey: data.apiKey });
}

Step 2 — Pass the key to the widget (your frontend)

import { lazy, Suspense, useMemo } from 'react';

const StudioWidgetLib = lazy(() =>
  import('@nexlylab/widget-react').then((m) => ({ default: m.StudioWidget }))
);

export function WidgetContainer() {
  const { data: currentUser } = useCurrentUser();
  const { data: endUserApiKey } = useWidgetEndUserKey(); // fetches eu_live_ from your backend

  const userContext = useMemo(
    () => (currentUser ? { userUuid: currentUser.id, name: currentUser.name } : undefined),
    [currentUser]
  );

  return (
    <Suspense fallback={null}>
      <StudioWidgetLib
        apiKey={process.env.REACT_APP_WIDGET_API_KEY!}
        serverUrl={process.env.REACT_APP_WIDGET_SERVER_URL!}
        endUserApiKey={endUserApiKey}     // ← autologin
        userContext={userContext}
        onError={(err) => console.warn('Widget error:', err.message)}
        onLogin={(user) => console.log('Widget: logged in as', user.name)}
      />
    </Suspense>
  );
}

Key types

| Key | Prefix | Scope | Where | |-----|--------|-------|-------| | Product API key | pk_live_… / pk_test_… | One per product | Always in browser as apiKey prop | | End-user API key | eu_live_… | One per user per product | Passed as endUserApiKey — safe in browser, never log or expose publicly |

Security

| Threat | Mitigation | |--------|-----------| | Cookie theft | Widget session uses httpOnly refresh cookies — inaccessible to JavaScript | | CSRF | Refresh endpoint is scoped per product and validated against the Origin header | | Replay | Access tokens expire in 15 min; refresh tokens rotate on each use | | Key revocation | Revoke individual end-user keys in Admin → Product → Users → Revoke without affecting other users | | Key leakage | eu_live_ keys are user-scoped — one leaked key does not expose others. Rotate immediately if leaked. |


3. HMAC SSO via signature (v2)

Your backend holds a shared secret and mints a short-lived HMAC-signed JWT per page load. The widget exchanges this token at POST /api/widget/auth/sso for a widget session. No per-user key provisioning required.

Use this when: you want no per-user state on your side and prefer a stateless, cryptographic flow.

How it works

Admin panel → generate SSO secret  →  your backend signs JWT (≤90s TTL)  →  frontend passes it as signature  →  widget POSTs to /api/widget/auth/sso  →  widget receives access + refresh tokens

Token requirements:

  • Algorithm: HS256
  • Max lifetime: 120 seconds (enforced server-side; use ≤90s to allow clock skew)
  • Replay protection: Redis-backed per-token nonce keyed by jti (or sub+iat)
  • Failed exchange: widget silently falls back to guest mode; onError fires

Step 1 — Generate the SSO secret

In the admin panel: Admin → Product → Settings → Single Sign-On → Generate Secret

Store WIDGET_SSO_SECRET in your backend environment. Never expose it to the browser.

Step 2 — Sign a JWT on your backend

Node.js (jose):

import { SignJWT } from 'jose';

async function getWidgetSsoToken(userId: string, userEmail: string): Promise<string> {
  const secret = new TextEncoder().encode(process.env.WIDGET_SSO_SECRET);
  const now = Math.floor(Date.now() / 1000);

  return new SignJWT({
    sub: userId,           // required — your app's user ID
    email: userEmail,      // optional — used for display / upsert
    name: 'Alice Smith',   // optional
    jti: crypto.randomUUID(), // prevents replay
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt(now)
    .setExpirationTime(now + 90)
    .sign(secret);
}

Node.js (jsonwebtoken):

import jwt from 'jsonwebtoken';

function getWidgetSsoToken(userId: string, userEmail: string): string {
  return jwt.sign(
    { sub: userId, email: userEmail, jti: crypto.randomUUID() },
    process.env.WIDGET_SSO_SECRET!,
    { algorithm: 'HS256', expiresIn: 90 }
  );
}

PHP (firebase/php-jwt):

use Firebase\JWT\JWT;

$token = JWT::encode([
    'sub'   => $userId,
    'email' => $userEmail,
    'iat'   => time(),
    'exp'   => time() + 90,
    'jti'   => bin2hex(random_bytes(16)),
], $_ENV['WIDGET_SSO_SECRET'], 'HS256');

Backend token endpoint (Next.js App Router):

// app/api/widget-sso-token/route.ts
import { SignJWT } from 'jose';
import { getServerSession } from 'next-auth';

export async function GET() {
  const session = await getServerSession();
  if (!session?.user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const secret = new TextEncoder().encode(process.env.WIDGET_SSO_SECRET);
  const now = Math.floor(Date.now() / 1000);

  const token = await new SignJWT({
    sub: session.user.id,
    email: session.user.email,
    name: session.user.name,
    jti: crypto.randomUUID(),
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt(now)
    .setExpirationTime(now + 90)
    .sign(secret);

  return Response.json({ token });
}

Step 3 — Pass the token to the widget

Fetch a fresh token on each page load — tokens expire after ≤90 seconds. Never cache or reuse them.

import { StudioWidget } from '@nexlylab/widget-react';

export function MyPage() {
  const { data: signedToken } = useFetch<string>('/api/widget-sso-token');
  const { data: currentUser } = useCurrentUser();

  return (
    <StudioWidget
      apiKey={process.env.REACT_APP_WIDGET_API_KEY!}
      serverUrl={process.env.REACT_APP_WIDGET_SERVER_URL!}
      signature={signedToken}               // ← HMAC SSO autologin
      userContext={{ userUuid: currentUser?.id }}
      onLogin={(user) => console.log('SSO login:', user.name)}
      onError={(err) => console.warn('Widget autologin failed:', err.message)}
    />
  );
}

Secret rotation

Rotate in Admin → Product → Settings → Single Sign-On → Rotate Secret. Active sessions remain valid until their refresh tokens expire (7 days). New SSO exchanges immediately require tokens signed with the new secret.

For full SSO protocol details, see docs/widget-sso.md.


Choosing an auth method

| Method | Best when | |--------|-----------| | Anonymous | Public content only, or users log in via the in-widget form | | endUserApiKey | You already issue per-user records; you want to store a key once and reuse it indefinitely | | signature (HMAC SSO) | You prefer stateless auth; you want no per-user key storage; you're integrating from scratch |

Both endUserApiKey and signature can coexist on the same <StudioWidget /> — only one is used per mount (endUserApiKey takes precedence if both are provided).


Allowed Origins

The Studio backend enforces CORS on every widget API response. Your site's origin must be listed in the admin panel before any widget API calls will succeed.

Admin → Product → Settings → Allowed Origins

Rules:

  • Add the exact origin with scheme: https://your-site.com (no trailing slash, no path)
  • Wildcards are not supported — list each origin explicitly
  • localhost and 127.0.0.1 origins must be added for local development: http://localhost:3000
  • On origin mismatch the browser receives a CORS error and blocks the response — the widget shows in guest mode and onError fires

Common mistakes:

  • Trailing slash: https://your-site.com/ ✗ → https://your-site.com ✓
  • Wrong scheme: http:// vs https:// — they are treated as different origins
  • Missing port: http://localhost ≠ http://localhost:3000

User Context

Auto-seeded context after login

Every successful widget login (password, endUserApiKey, HMAC SSO) automatically writes a row to end_user_context on the server with the fields the backend already knows (name, email, language, authMethod, externalId). No action is required from the host site for this baseline context.

Widget auto-POST of the userContext prop

When you supply a userContext prop, the widget also automatically POSTs it to POST /api/widget/user-context with source: "widget" after every successful login and whenever the userContext reference changes. This keeps the server-side context in sync with whatever your host page provides — no manual API call needed.

Merge-precedence rules for contextData:

| Source | Example fields | Priority | |--------|---------------|----------| | userContext.metadata | plan, region, custom fields | Lower | | userContext.name / userContext.language | top-level props | Higher (wins on collision) |

So if both metadata.name and userContext.name are set, the top-level name wins:

userContext={{
  userUuid: 'user-1',
  name: 'Alice',              // wins → contextData.name === 'Alice'
  metadata: { name: 'from-metadata', plan: 'pro' },
}}
// contextData sent → { name: 'Alice', plan: 'pro' }

Failures are non-blocking — a network error or 4xx response is silently absorbed and never surfaces to the user.

For richer context that the widget doesn't know about — page URL, WooCommerce login state — your backend should push it via the Studio REST API:

POST /api/widget/user-context
X-API-Key: <product-api-key>
Content-Type: application/json

{
  "endUserId": "<end-user-id>",
  "contextData": {
    "pageUrl": "https://shop.example.com/checkout",
    "plan": "pro",
    "wcLoggedIn": true
  }
}

The upsert is a shallow-merge — auto-seeded keys are preserved unless the host explicitly overwrites them.

The userContext prop

Pass userContext to associate every AI chat message with your app's user identity. The widget forwards this data to the AI orchestrator on each request.

<StudioWidget
  apiKey="pk_live_xxx"
  serverUrl="https://your-studio-server.com"
  userContext={{
    userUuid: 'user-abc-123',    // required — your user ID
    name: 'Jane Doe',            // shown in widget header
    language: 'ko',              // preferred language
    metadata: {                  // forwarded verbatim to AI context
      plan: 'enterprise',
      region: 'APAC',
      customField: 'value',
    },
  }}
/>

The userUuid field is also used to scope real-time socket room subscriptions — messages and notifications are only delivered to that user's socket rooms.

Note: user_id is deprecated. Use userUuid instead. Both are accepted for backwards compatibility.


Theming

Theme prop

<StudioWidget theme="dark" />      // always dark
<StudioWidget theme="light" />     // always light
<StudioWidget theme="system" />    // auto-detect (default)

'system' detects the host page's theme from:

  1. <html class="dark"> / <html class="light"> (Tailwind, shadcn, daisyUI)
  2. <html data-theme="dark"> / <html data-mode="dark">
  3. <body class="dark-mode"> / <body class="light-mode">
  4. OS prefers-color-scheme media query (fallback)

Live updates: the widget watches for class and data-attribute changes on <html> via MutationObserver and updates immediately when the host page toggles its theme.

Users can also override the theme from the widget's Settings view — this preference is stored per-session.

CSS custom property overrides

Use customStyles to pass CSS custom properties (CSS variables) that override the widget's design tokens. Only -- prefixed keys are accepted — other keys are silently ignored.

<StudioWidget
  customStyles={{
    '--es-color-primary': '#7c3aed',
    '--es-color-primary-hover': '#6d28d9',
    '--es-border-radius': '16px',
    '--es-font-family': '"Inter", sans-serif',
  }}
/>

Available tokens are documented in the widget's CSS file. The prefix for all widget tokens is --es-.

Block and tab configuration

All views, tabs, and content blocks visible inside the widget are configured in the admin panel — no code changes required. Navigate to:

Admin → Product → Widget → Builder

From the builder you can:

  • Add, reorder, and rename tabs
  • Assign content blocks to tabs
  • Configure each block's data sources and behavior
  • Preview the widget in real time

Privacy note — Learning media embeds. If allowMediaEmbeds is enabled for a product, the Learning surface shows a poster frame on each video tile (the player itself stays click-to-load). The widget contacts no third party to do it. Since PECS-784 the poster is fetched server-side once, stored in the same media bucket as every other lesson image, and served from there — so a video tile issues no request to Google, and no learner IP or User-Agent reaches YouTube until the learner actually presses play. A video with no stored poster renders a plain tile; there is no fallback to a YouTube-hosted image. Support-chat embeds are likewise request-free until click.

This replaces the earlier posture, in which the tile loaded its thumbnail directly from i.ytimg.com at render time. Hosts that added a host-side privacy disclosure for that request (as the superseded determination in docs/privacy/pecs-607-thumbnail-legal-basis.md required) no longer need one for the thumbnail — there is no longer any such processing to disclose. What a host does still need is img-src coverage for the media bucket the widget's images come from, which is the same allowlist entry lesson images already require; without it the poster is blocked like any other image.


i18n — Translations

The widget ships with built-in English fallback strings. On initialization it fetches the active translation set from the Studio server (GET /api/widget/translations?language=en).

After a user logs in, the widget automatically loads their preferred language (from their user profile). Language changes in the Settings view are persisted to the user's profile via PUT /api/widget/profile.

Supported languages: en (English), ko (Korean). Additional languages can be added via the admin panel.

Admin panel: Admin → Translations — manage all widget string keys and their translations. Changes are reflected immediately without a widget redeploy.

Seeding translations locally:

make seed-translations   # Seeds EN + KO translation sets

Events & Lifecycle

Widget lifecycle

| Event | When fired | |-------|-----------| | onReady | API key validated, widget is ready to render | | onError | Invalid API key format, or endUserApiKey / SSO autologin failure | | onLogin | User successfully logged in (any method) | | onLogout | User logged out |

onError does not unmount the widget — it continues in guest mode. Use it to log errors or show a toast.

Real-time events

These fire only when serverUrl is set and the socket is connected.

| Event | When fired | |-------|-----------| | onConnectionChange(connected) | Socket connects (true) or disconnects (false) | | onUnreadCountChange(count) | Unread badge count changes | | onMessage(msg) | New AI chat message arrives | | onNotification(n) | New chat notification arrives |

ChatMessagePayload shape:

type ChatMessagePayload = {
  sessionId: string;
  jobId: string | null;
  content: string;
  metadata: {
    sourcesUsed: string[];
    unavailableSources: string[];
    sentiment: string | null;
    eventCount: number;
    dataQuality: 'FULL' | 'PARTIAL' | 'MINIMAL' | 'NONE';
    confidenceScore: number;
  };
};

Keyboard interactions

| Key | Action | |-----|--------| | Escape | Close panel (or exit fullscreen if in fullscreen mode) | | Tab | Navigate between focusable elements in the panel |

The floating button and panel are keyboard-accessible and follow ARIA tab panel patterns.


TypeScript Exports

The package root exports everything: the component, the error surface and the prop and payload types. Up to 2.x the types came from a second entry, /core, which also shipped the bundled component and its ~30 supporting symbols; 3.0.0 removes that entry, so a host that wanted to annotate a userContext no longer has to reach into a bundle it was told not to use.

// The component and its props
import { StudioWidget } from '@nexlylab/widget-react';
import type { WidgetProps } from '@nexlylab/widget-react';

// Load-failure surface — see "Error handling" above
import { isWidgetRemoteError } from '@nexlylab/widget-react';
import type { WidgetRemoteError, WidgetRemoteErrorCode } from '@nexlylab/widget-react';

// Error boundary, if you want one of your own around the widget
import { ShellErrorBoundary } from '@nexlylab/widget-react';

// Auth types
import type {
  AuthState,
  AuthErrorCode,
  UserAuthState,
  WidgetUser,
} from '@nexlylab/widget-react';

// Socket types
import type {
  SocketConnectionState,
  ChatMessagePayload,
  ChatNotificationPayload,
} from '@nexlylab/widget-react';

// User types
import type {
  UserContext,
  WidgetTheme,
  WidgetCustomStyles,
} from '@nexlylab/widget-react';

WidgetProps is WidgetCoreProps plus the three the shell adds: serverUrl (required here, see the Props Reference), fallback and importModule.

Migration note (3.0.0): every @nexlylab/widget-react/core import moves to the package root — the subpath is gone, and so is WidgetCore. Use StudioWidget from the root: the props are the same, and the shell is what makes widget releases reach your users without a rebuild. The one prop to check is serverUrl, which the shell requires.

Two types the old /core entry exported are NOT on the root, deliberately: WidgetShellConfig and the props that carried it (configOverride, builderMode, builderPresentation, previewSession). They exist for Studio's own builder previews, which mount the widget directly rather than through this shell; no host could construct them without importing the bundle.


Configuration Examples

Minimal — anonymous guest mode

import { StudioWidget } from '@nexlylab/widget-react';

function App() {
  return (
    <StudioWidget
      apiKey="pk_live_xxx"
      serverUrl="https://your-studio-server.com"
    />
  );
}

Standard — logged-in user with context

import { StudioWidget } from '@nexlylab/widget-react';

function App({ user }) {
  return (
    <StudioWidget
      apiKey="pk_live_xxx"
      serverUrl="https://your-studio-server.com"
      theme="system"
      brandName="My Assistant"
      userContext={{
        userUuid: user.id,
        name: user.name,
        language: user.preferredLanguage,
        metadata: { plan: user.plan },
      }}
      onReady={() => console.log('Widget ready')}
      onError={(err) => console.warn(err.message)}
      onUnreadCountChange={(count) => updateBadge(count)}
    />
  );
}

Advanced — lazy-loaded, endUserApiKey autologin

import { lazy, Suspense, useMemo } from 'react';

const StudioWidgetLib = lazy(() =>
  import('@nexlylab/widget-react').then((m) => ({ default: m.StudioWidget }))
);

export function WidgetContainer() {
  const { data: currentUser, isSuccess } = useCurrentUser();
  const { data: endUserApiKey } = useWidgetEndUserKey(); // fetches eu_live_ from your backend

  const userContext = useMemo(
    () => (currentUser ? { userUuid: currentUser.id, name: currentUser.name } : undefined),
    [currentUser]
  );

  if (!isSuccess) return null;

  return (
    <Suspense fallback={null}>
      <StudioWidgetLib
        apiKey={process.env.REACT_APP_WIDGET_API_KEY!}
        serverUrl={process.env.REACT_APP_WIDGET_SERVER_URL!}
        endUserApiKey={endUserApiKey}
        userContext={userContext}
        position="bottom-right"
        theme="system"
        onError={(err) => console.warn('Widget error:', err.message)}
        onLogin={(user) => analytics.track('widget_login', { userId: user.id })}
      />
    </Suspense>
  );
}

Next.js App Router

// app/components/WidgetLoader.tsx
'use client';

import dynamic from 'next/dynamic';

const StudioWidgetLib = dynamic(
  () => import('@nexlylab/widget-react').then((m) => ({ default: m.StudioWidget })),
  { ssr: false }   // widget relies on browser APIs — disable SSR
);

export function WidgetLoader({ apiKey, serverUrl }: { apiKey: string; serverUrl: string }) {
  return (
    <StudioWidgetLib
      apiKey={apiKey}
      serverUrl={serverUrl}
      theme="system"
    />
  );
}

// app/layout.tsx
import { WidgetLoader } from './components/WidgetLoader';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <WidgetLoader
          apiKey={process.env.NEXT_PUBLIC_WIDGET_API_KEY!}
          serverUrl={process.env.NEXT_PUBLIC_WIDGET_SERVER_URL!}
        />
      </body>
    </html>
  );
}

HMAC SSO autologin

'use client';
import { StudioWidget } from '@nexlylab/widget-react';
import { useEffect, useState } from 'react';

export function WidgetWithSSO({ userId }: { userId: string }) {
  const [signature, setSignature] = useState<string | undefined>();

  useEffect(() => {
    // Fetch a fresh token on each page load — never cache SSO tokens
    fetch('/api/widget-sso-token')
      .then((r) => r.json())
      .then(({ token }) => setSignature(token));
  }, [userId]);

  return (
    <StudioWidget
      apiKey={process.env.NEXT_PUBLIC_WIDGET_API_KEY!}
      serverUrl={process.env.NEXT_PUBLIC_WIDGET_SERVER_URL!}
      signature={signature}
      userContext={{ userUuid: userId }}
      onError={(err) => console.warn('SSO failed, guest mode:', err.message)}
    />
  );
}

Troubleshooting

Widget shows "API key is required" error Pass a valid apiKey prop starting with pk_live_ or pk_test_. Never use a secret key — only public product API keys are accepted in the browser.

Widget autologin does nothing / user lands as guest

  1. Ensure your backend returns a valid eu_live_xxx key — not undefined or an empty string.
  2. Confirm the pk_live_xxx used during register matches the apiKey prop.
  3. Add your site's origin to Admin → Product → Settings → Allowed Origins.
  4. Check onError — if autologin fails it fires with the reason before falling back to guest mode.

HMAC SSO autologin fails silently

  1. Verify the WIDGET_SSO_SECRET environment variable matches the secret shown in Admin → Product → Settings → Single Sign-On.
  2. Confirm the JWT exp claim is ≤120 seconds from issue time (the server rejects longer-lived tokens).
  3. Ensure each token has a unique jti — replayed tokens are rejected by the Redis nonce cache.
  4. Check that your site's origin is listed in Allowed Origins.

CORS error in the browser console Your site's origin is not in Allowed Origins. Add it exactly: https://your-site.com (no trailing slash). Scheme and port are part of the origin — http://localhost:3000 ≠ https://your-site.com.

Widget connects but chat doesn't work Ensure serverUrl points to a running Studio backend and the socket endpoint is reachable. Check the browser Network tab for failed WebSocket or polling requests.

Widget shows in wrong theme For theme="system", the widget reads <html class="dark"> (or data-theme, data-mode). If your app uses a different pattern, pass theme="light" or theme="dark" explicitly.

TypeScript: cannot find module @nexlylab/widget-react Ensure the package is installed and tsconfig.json uses "moduleResolution": "bundler" or "node16". The package uses the exports field which requires modern module resolution.

Styles not applied / widget looks unstyled CSS is auto-injected by the component — no manual import needed. If styles still don't appear, check that your bundler isn't tree-shaking side effects. The package declares "sideEffects": true in package.json to prevent this.

onReady fires but widget is blank This usually means the widget loaded but has no views configured. Set up tabs and blocks in Admin → Product → Widget → Builder.

Peer dependency warning for React The package supports React 17, 18, and 19. Install whichever version your app uses:

pnpm add react react-dom

Publishing

Releases are driven by changesets. Full flow, configuration, and troubleshooting: docs/widget-release.md (canonical source).

Every change ships with a changeset

A PR that touches packages/widget-react/ must include a changeset file, otherwise no release happens:

pnpm changeset    # pick major/minor/patch, write the CHANGELOG line

| Bump | When | |---------|-----------------------------------| | patch | bug fixes | | minor | new, backwards-compatible feature | | major | breaking change |

Merging that PR does not publish anything — there is no push trigger. A human opens the chore(widget): release @nexlylab/[email protected] bump PR (pnpm version-packages), and once it lands someone dispatches Actions → Widget Release, which publishes the pair to npmjs and opens the bump PRs in cm-frontend (release-next first, then master). Full process: docs/widget-release.md.

Cutting a release locally

⚠️ make widget-publish publishes immediately, from your machine — it is the manual track, not a dry run. Both packages go to npmjs before it returns, and a version number can never be reused.

NPM_TOKEN=<npmjs automation token with publish rights on @nexlylab> make widget-publish BUMP=minor
git push   # push the release commit, or main drifts from the registry

In order: guards (NPM_TOKEN present → branch is main → the publishable set is still exactly the pair) → widget tests → changeset version computes the number → build → pnpm publish of both @nexlylab/widget-react and @nexlylab/widget to npmjs → a local chore(widget): release @nexlylab/[email protected] commit.

Two things it does not do. It does not run propagation — for the cm-frontend bump PRs, dispatch Actions → Widget Release with propagate: true once the commit lands on main. And it refuses to publish from any branch other than main, because what ships to a public registry has to be code main has reviewed.

The version is always computed by changeset version — the same code CI runs. Nothing is bumped by hand. On token choice and handling (never in .env), see docs/widget-release.md § Token do toru ręcznego.

Build output

| File | Format | |------|--------| | dist/widget-react.js | ESM | | dist/widget-react.cjs | CommonJS | | dist/index.d.ts | TypeScript declarations |


Requirements

  • React 17.x or newer (provided by host application)
  • axios 1.0+ (peer dependency)
  • Modern browser (ES2020+, no IE11 support)

License

MIT