@returningai/widget-sdk
v1.1.0
Published
Shadow DOM isolated widget SDK for ReturningAI
Readme
ReturningAI Widget SDK
A Web Component SDK for embedding ReturningAI widgets on any customer website. Supports both iframe mode (Shadow DOM isolation) and bundle mode (direct DOM rendering). Drop-in replacement for the legacy widget-loader.js.
Table of Contents
Architecture
The SDK renders widgets in one of two modes, chosen automatically by the presence of the bundle-url attribute:
Iframe Mode (default)
Customer page DOM
└── <rai-store-widget> ← custom element (or any type-specific tag)
└── Shadow Root [closed]
├── <style> ← all CSS scoped here, never leaks
├── <div class="rai-loader"> ← animated loader (hidden after ready)
├── <div class="rai-error"> ← error state (hidden until error)
└── <iframe src="widget-url"> ← actual widget contentBundle Mode (when bundle-url is set)
Customer page DOM
└── <rai-store-widget> ← custom element (or any type-specific tag)
├── Shadow Root [closed]
│ ├── <style> ← SDK loader/error CSS
│ └── <slot> ← renders light-DOM children through shadow
└── <div> (light DOM) ← widget mounts here, CSS cascade works normally
└── [React app from IIFE bundle]Bundle mode loads the widget's JavaScript bundle directly into the page (no iframe). CSS cascades from html[data-theme] into the widget naturally, making theme integration seamless.
Module Responsibilities
| Module | File | Purpose |
|--------|------|---------|
| Base Web Component | src/BaseWidget.ts | Abstract HTMLElement subclass; owns Shadow Root, auth lifecycle, iframe/bundle rendering, lazy loading, DOM events, public API |
| Store Widget | src/StoreWidget.ts | Extends BaseWidget; implements buildWidgetUrl() for the store micro-frontend |
| Channel Widget | src/ChannelWidget.ts | Extends BaseWidget; appends encoded channel ID to URL |
| Milestone Widget | src/MilestoneWidget.ts | Extends BaseWidget; same URL strategy as ChannelWidget |
| Social Widget | src/SocialWidget.ts | Extends BaseWidget; same URL strategy as ChannelWidget |
| Currency Widget | src/CurrencyWidget.ts | Extends BaseWidget; same URL strategy as ChannelWidget |
| Auth | src/core/auth.ts | Serverless auth with exponential backoff retry, proxy auth (authenticated embed), embed token validation (access key embed), token refresh (concurrency lock), logout, error settings fetch |
| Storage | src/core/storage.ts | localStorage helpers scoped to {prefix}-{communityId}-*; access token never written to disk |
| postMessage | src/core/postmessage.ts | Sends token (+ optional customData) to iframe; debounced height updates; emits DOM events; handles WIDGET_READY, WIDGET_HEIGHT_UPDATE, WIDGET_LOGOUT, RETURNINGAI_WIDGET_REQUEST_TOKEN |
| Styles | src/styles/widget.css | Loader animation, error state, retry button, iframe fade-in — all scoped inside Shadow DOM |
| Entry | src/index.ts | Registers all 5 custom elements; bootstraps from <script> tag for legacy embeds; routes by widget-type; exposes window.ReturningAIWidget |
Auth Flow
Page load
│
├─ embed-token present? (Access Key Embed)
│ Yes ──► validateEmbedToken()
│ ├─ valid ──► continue below
│ └─ invalid ──► showError() — stop
│ No ──► skip validation, continue below
│
├─ auth-url present? (Authenticated Embed)
│ Yes ──► authenticateViaProxy() ──► launch widget
│ No ──► loadFromStorage()
│ ├─ refresh token found ──► refreshAccessToken() ──► launch widget
│ └─ no token ──► authenticateServerless() ──► launch widget
│
└─ launch widget
├─ bundle-url present? ──► mountWidget() (load IIFE, render in light DOM)
└─ no bundle-url ──► createIframe() (standard iframe mode)Access Key Embed flow (credential exchange happens entirely server-side):
Customer server
│ POST /v2/api/widget-access-keys/token
│ Body: { accessId, accessKey } ← stored in env vars, never in HTML
▼
RAI backend — validates credentials, signs 15-min JWT
│ Response: { embedToken, expiresIn: 900 }
▼
Customer server injects embedToken into HTML
│ <rai-store-widget embed-token="eyJ...">
▼
Browser loads page — SDK reads embed-token attribute
│ POST /v2/api/widget-access-keys/validate
│ Body: { embedToken }
▼
RAI backend — verifies JWT signature + checks key not revoked
├─ valid ──► widget initialises normally
└─ invalid ──► showError()Token storage strategy
| Token | Location | TTL |
|-------|----------|-----|
| Access token | Memory only (WidgetState.accessToken) | ~5 min |
| Refresh token | localStorage | 7 days |
The access token is never written to localStorage. On every page load the refresh token is exchanged for a fresh access token before the widget mounts.
postMessage Protocol (iframe mode only)
Messages the SDK sends to the widget iframe:
| Type | Payload | When |
|------|---------|------|
| RETURNINGAI_WIDGET_TOKEN | { widgetId, token, customData? } | After iframe load, on refresh, every 2 min |
Messages the SDK receives from the widget iframe:
| Type | Action |
|------|--------|
| WIDGET_READY | Hides loader, fades in iframe |
| WIDGET_HEIGHT_UPDATE | Resizes iframe to payload.height |
| WIDGET_ERROR | Hides loader |
| WIDGET_LOGOUT | Calls logout endpoint, removes iframe |
| RETURNINGAI_WIDGET_REQUEST_TOKEN | Responds immediately with a fresh token |
All messages are origin-validated against config.widgetDomain.
Embed Modes
The SDK supports three embed modes depending on your security requirements.
| | Public Embed | Access Key Embed | Authenticated Embed |
|---|---|---|---|
| Credentials in HTML | None | Short-lived token only | None |
| Backend required | No | Yes — one call per page load | Yes — one endpoint |
| Who generates auth | SDK calls RAI directly | Your server calls RAI, then token in HTML | Your server calls RAI with API key |
| embed-token attribute | Absent | Required | Absent |
| auth-url attribute | Absent | Absent | Required |
Shadow DOM Implementation
In iframe mode, the SDK uses a closed Shadow Root (mode: 'closed'), which means:
- Customer page CSS cannot reach any element inside the widget
- The widget's loader and error styles are fully encapsulated
- No class name collisions with customer frameworks (Tailwind, Bootstrap, etc.)
In bundle mode, widget content renders in the light DOM (through a <slot>) so that html[data-theme] and Tailwind CSS cascade into the widget naturally.
Theme variables (iframe mode)
| Variable | Light | Dark |
|----------|-------|------|
| --rai-accent | #000000 | #ffffff |
| --rai-text4 | #6b7280 | #9ca3af |
| --rai-loader-bg | #ffffff | #1a1a1a |
| --rai-error-bg | #ffffff | #1a1a1a |
Dependencies
Runtime
None. The SDK ships as a single self-contained IIFE with no external runtime dependencies.
Dev / Build
| Package | Version | Purpose |
|---------|---------|---------|
| vite | ^5.1 | Bundles TypeScript + inlines CSS into a single IIFE |
| typescript | ^5.3 | Type checking and compilation |
| terser | ^5.46 | Minification for production builds |
Browser requirements
| Feature | Minimum |
|---------|---------|
| Custom Elements v1 | Chrome 67, Firefox 63, Safari 13 |
| Shadow DOM v1 | Chrome 53, Firefox 63, Safari 10 |
| crypto.randomUUID() | Chrome 92, Firefox 95, Safari 15.4 |
| IntersectionObserver | Chrome 58, Firefox 55, Safari 12.1 |
| localStorage | All modern browsers |
Usage
Script Tag (Legacy Embed)
Zero changes required to existing embed HTML. Point src at the SDK and keep all data-* attributes as-is:
<!-- 1. Container div (unchanged from current embed) -->
<div
id="returning-ai-widget-YOUR_COMMUNITY_ID"
style="width: 100%; height: 600px;"
></div>
<!-- 2. SDK script tag -->
<script
src="https://unpkg.com/@returningai/widget-sdk/dist/rai-widget.iife.js"
data-community-id="YOUR_COMMUNITY_ID"
data-widget-type="store"
data-container="returning-ai-widget-YOUR_COMMUNITY_ID"
data-theme="dark"
data-width="100%"
data-height="600px"
data-api-url="YOUR_API_URL"
data-widget-url="YOUR_WIDGET_URL"
data-auto-refresh="true"
data-email="[email protected]"
></script>The SDK scans for the loader <script> tag, reads its data-* attributes, creates the appropriate widget element, and mounts it inside the container div.
Access Key Embed
Credentials live only in your server environment. Your server calls RAI to get a short-lived embed token and injects it into the page response — no secret ever reaches the browser.
Your server env:
RAI_ACCESS_ID = rai_...
RAI_ACCESS_KEY = pk_... ← never in HTML
Per-request (server-side):
POST /v2/api/widget-access-keys/token
→ { embedToken, expiresIn: 900 }
Page HTML:
<rai-store-widget embed-token="eyJ..." ...>Node.js / Express example:
app.get('/page', async (req, res) => {
const { data } = await fetch(`${process.env.RAI_API_URL}/v2/api/widget-access-keys/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accessId: process.env.RAI_ACCESS_ID,
accessKey: process.env.RAI_ACCESS_KEY,
}),
}).then(r => r.json())
res.render('page', { embedToken: data.embedToken })
})<!-- page.html -->
<rai-store-widget
community-id="YOUR_COMMUNITY_ID"
api-url="YOUR_API_URL"
embed-token="<%= embedToken %>"
data-email="[email protected]"
></rai-store-widget>The SDK validates the token on load. If expired (> 15 min since page render), the widget shows the error screen — users should refresh the page.
Web Component
For customers using a JavaScript framework (React, Vue, Angular), import the SDK once and use the custom element directly. Each widget type has its own tag:
| Tag | Widget type |
|-----|-------------|
| <rai-store-widget> | store |
| <rai-channel-widget> | channel |
| <rai-milestone-widget> | milestone |
| <rai-social-widget> | social |
| <rai-currency-widget> | currency-view |
| <rai-widget> | store (deprecated alias — use <rai-store-widget>) |
<script src="https://unpkg.com/@returningai/widget-sdk/dist/rai-widget.iife.js"></script>
<!-- Store widget -->
<rai-store-widget
community-id="YOUR_COMMUNITY_ID"
theme="dark"
width="100%"
height="600px"
api-url="YOUR_API_URL"
widget-url="YOUR_WIDGET_URL"
data-email="[email protected]"
></rai-store-widget>
<!-- Channel widget (requires channel-id) -->
<rai-channel-widget
community-id="YOUR_COMMUNITY_ID"
channel-id="YOUR_CHANNEL_ID"
theme="dark"
api-url="YOUR_API_URL"
widget-url="YOUR_WIDGET_URL"
data-email="[email protected]"
></rai-channel-widget>In React (TypeScript):
// JSX types are included in the package — no manual declarations needed
import '@returningai/widget-sdk'
export function WidgetEmbed() {
return (
<rai-store-widget
community-id="YOUR_COMMUNITY_ID"
theme="dark"
height="600px"
data-email={currentUser.email}
/>
)
}Bundle Mode (Non-Iframe)
When you set the bundle-url attribute, the widget renders directly in the page DOM instead of inside an iframe. This gives full CSS cascade — html[data-theme] and Tailwind classes apply to the widget content naturally.
<rai-store-widget
community-id="YOUR_COMMUNITY_ID"
api-url="YOUR_API_URL"
bundle-url="/path/to/store-widget.js"
embed-token="eyJ..."
data-email="[email protected]"
theme="dark"
width="100%"
height="100vh"
eager
></rai-store-widget>Each widget type has its own IIFE bundle and global name:
| Widget type | Bundle global |
|-------------|--------------|
| store | RaiStoreWidget |
| channel | RaiChannelWidget |
| social | RaiSocialWidget |
| milestone | RaiMilestoneWidget |
| currency-view | RaiCurrencyWidget |
The bundle must export a mount(container, config) function on its global (e.g. window.RaiStoreWidget.mount).
Configuration Attributes
All attributes can be provided with or without the data- prefix.
| Attribute | Required | Default | Description |
|-----------|----------|---------|-------------|
| community-id | Yes | — | Your community ID — from Community Settings |
| channel-id | Channel only | — | Channel ID — required when widget-type is channel |
| widget-type | No | store | store, channel, milestone, social, currency-view |
| theme | No | light | light or dark |
| container | No | returning-ai-widget-{id} | ID of the container element |
| width | No | 100% | CSS width |
| height | No | 600px | Initial CSS height (auto-resized by WIDGET_HEIGHT_UPDATE in iframe mode) |
| api-url | No | — | Auth API base URL — from Community Settings |
| widget-url | No | — | URL served inside the iframe — from Community Settings (iframe mode only) |
| bundle-url | No | — | URL to the widget IIFE bundle — triggers bundle mode (non-iframe) |
| embed-token | Access Key only | — | Short-lived JWT from your server. Required for Access Key Embed; absent for Public and Authenticated Embed |
| auth-url | Auth Embed only | — | Your backend auth endpoint — enables Authenticated Embed mode |
| auto-refresh | No | true | Automatically refresh access token before expiry |
| debug | No | false | Enable verbose console logging |
| eager | No | — | Boolean — skip IntersectionObserver, init immediately on mount |
| locale | No | — | BCP 47 tag appended as ?locale= to the widget URL (e.g. fr-FR) |
| max-retries | No | 3 | Max auth retry attempts on network error or 5xx |
| retry-delay | No | 500 | Base backoff delay in ms; doubles each attempt |
| height-debounce | No | 100 | Debounce window in ms for WIDGET_HEIGHT_UPDATE |
| storage-prefix | No | returning-ai-widget | localStorage key prefix — set per tenant to avoid collisions |
| retry-label | No | Retry | Text for the retry button on the error screen |
| custom-data | No | — | JSON string forwarded as customData in the token postMessage |
| data-email | No | — | User identifier passed to auth (Public Embed only) |
| data-* | No | — | Any additional data-* attributes are forwarded as userIdentifiers to the auth API |
Note: The legacy
widget-idattribute is still supported as a deprecated alias forcommunity-id.
DOM Events
All events bubble and are composed: true (cross the Shadow DOM boundary).
| Event | detail | Fired when |
|-------|----------|-----------|
| rai-authenticated | {} | Auth succeeded, before widget mounts |
| rai-ready | {} | WIDGET_READY received, loader hidden (iframe mode) |
| rai-mounted | {} | Widget bundle mounted successfully (bundle mode) |
| rai-error | { message } | Auth failed after all retries |
| rai-logout | {} | Widget logged out |
| rai-height-change | { height } | iframe resized (after debounce) |
document.querySelector('rai-channel-widget')
.addEventListener('rai-error', (e) => showToast(e.detail.message))Public API
After the SDK loads, window.ReturningAIWidget is available:
// Check the loaded version
window.ReturningAIWidget.version // e.g. "1.0.3"
// Reload the widget (re-runs auth flow)
await window.ReturningAIWidget.reload()
// Log out and remove the iframe
await window.ReturningAIWidget.logout()
// Check authentication state
window.ReturningAIWidget.isAuthenticated() // boolean
// Inspect token metadata (no token values exposed)
window.ReturningAIWidget.getTokenInfo()
// {
// hasAccessToken: true,
// hasRefreshToken: true,
// accessTokenExpiry: Date,
// refreshTokenExpiry: Date,
// isAccessTokenValid: true,
// isRefreshTokenValid: true
// }Build Process
Prerequisites
node >= 18
npm >= 9Install
cd rai-widget-sdks
npm installDevelopment build (unminified, fast)
npm run build
# → dist/rai-widget.iife.js (~17 kB, IIFE for CDN/script-tag)
# → dist/rai-widget.js (~17 kB, ESM for bundlers/npm)Production build (minified with Terser)
npm run build:min
# → dist/rai-widget.iife.js (~6 kB gzip, IIFE for CDN/script-tag)
# → dist/rai-widget.js (~6 kB gzip, ESM for bundlers/npm)Local dev server
npm run dev
# Opens Vite dev server — open test/index.html via the dev server URLHow the build works
- Entry:
src/index.ts— imports all widget subclasses, registers the custom elements, runs the bootstrap - CSS inlining:
src/styles/widget.cssis imported with Vite's?inlinesuffix, which converts it to a JavaScript string at build time — no separate CSS file is emitted - Output formats:
iife(Immediately Invoked Function Expression) for CDN/script-tag embeds →dist/rai-widget.iife.js;es(ES module) for bundlers and npm consumers →dist/rai-widget.js - Version injection:
vite.config.tsreadsversionfrompackage.jsonand replaces the__WIDGET_VERSION__placeholder at build time
To release a new version, bump version in package.json, rebuild, and upload dist/rai-widget.iife.js to the CDN under the new version path. Run npm publish to push the ESM build to the npm registry (prepublishOnly handles the rebuild automatically).
Project Structure
rai-widget-sdks/
├── src/
│ ├── types.ts # WidgetConfig, WidgetState, TokenData interfaces
│ ├── BaseWidget.ts # Abstract HTMLElement subclass — Shadow Root, auth, iframe/bundle
│ ├── StoreWidget.ts # Extends BaseWidget; store micro-frontend URL builder
│ ├── ChannelWidget.ts # Extends BaseWidget; channel URL builder
│ ├── MilestoneWidget.ts # Extends BaseWidget; milestone URL builder
│ ├── SocialWidget.ts # Extends BaseWidget; social URL builder
│ ├── CurrencyWidget.ts # Extends BaseWidget; currency-view URL builder
│ ├── index.ts # Registers all 5 custom elements + IIFE bootstrap
│ ├── jsx.d.ts # React JSX IntrinsicElements + Vue GlobalComponents type shims
│ ├── vite-env.d.ts # Type declarations for ?inline imports + __WIDGET_VERSION__
│ ├── core/
│ │ ├── auth.ts # Serverless auth, refresh, logout, error settings
│ │ ├── storage.ts # localStorage helpers (refresh token persistence)
│ │ └── postmessage.ts # iframe token delivery + message listener
│ └── styles/
│ └── widget.css # All loader/error/iframe CSS (inlined into Shadow DOM)
├── test/
│ └── index.html # Local test page with config panel and verification checklist
├── dist/ # Built output (gitignored)
│ ├── rai-widget.iife.js # IIFE bundle for CDN / <script src> embeds
│ ├── rai-widget.js # ESM bundle for bundlers / npm consumers
│ └── types/ # TypeScript declarations (generated by tsc)
├── package.json
├── tsconfig.json
└── vite.config.ts