A Pexip Web App 3 plugin that adds a Chat toolbar button and bridges the meeting to an external chat experience hosted by the page that embeds Web App 3 (the top window).
The plugin and the top window communicate over window.postMessage. The plugin
also exposes the Web App 3 conference.dialOut capability so the top window can
dial participants into the meeting.
ββββββββββββββββββββββββββββββββ
β Top window (embedding page) β
β β
β βββββββββββββββββββββββββ β postMessage (both directions)
β β Web App 3 (iframe) β β
β β ββββββββββββββββββ ββββΌβββββββββββββββββββββββββββββββββ
β β β external-chat ββββΌβββΌβββββββββββββββββββββββββββββββββ
β β β plugin β β β
β β ββββββββββββββββββ β β
β βββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββ
.
βββ Makefile # build + package pipeline (run from repo root)
βββ external-chat/ # the plugin source (Vite + TypeScript)
β βββ src/index.ts # plugin entry point
β βββ package.json # version is the source of truth for releases
βββ webapp3/
βββ branding/
βββ manifest.json # Web App 3 branding manifest (registers the plugin)
βββ plugins/external-chat/ # built plugin is copied here by `make deploy`
The plugin
idinexternal-chat/src/index.ts(registerPlugin({ id: 'external-chat' })) must match the pluginidinwebapp3/branding/manifest.json(plugins[].id). A mismatch causes Web App 3 to reject the plugin at load time.
All messages β in both directions β share a single envelope: a flat object with
an action string plus any payload fields alongside it.
{ action: 'pexip:plugin:external-chat/<name>', ...payloadFields }Every action is namespaced with the pexip:plugin:external-chat/ prefix.
Messages without that prefix are ignored (and logged as a warning).
The plugin only reacts to messages whose event.source is the top window.
Messages originating from the plugin's own iframe or sibling frames are dropped.
If your deployment has a fixed host origin, you should additionally gate on
event.origin in src/index.ts for defense in depth.
Listen for these in the top window:
window.addEventListener('message', (event) => {
if (event.source !== document.querySelector('iframe#webapp3')?.contentWindow) return;
const { action, ...data } = event.data ?? {};
switch (action) {
case 'pexip:plugin:external-chat/ready': /* plugin is loaded */ break;
case 'pexip:plugin:external-chat/connected': /* user joined the call */ break;
case 'pexip:plugin:external-chat/disconnected': /* data.userInitiated, data.error, data.errorCode */ break;
case 'pexip:plugin:external-chat/toggle-chat': /* data.active */ break;
case 'pexip:plugin:external-chat/dial-out-success': /* data.uuid, data.displayName */ break;
case 'pexip:plugin:external-chat/dial-out-error': /* data.message */ break;
}
});| Action | Payload | When |
|---|---|---|
pexip:plugin:external-chat/ready |
(none) | Plugin finished loading and registering (before the user joins). |
pexip:plugin:external-chat/connected |
(none) | User joined the call (passed preflight); the in-meeting toolbar and Chat button are now visible/accessible. |
pexip:plugin:external-chat/disconnected |
{ userInitiated: boolean, error?: string, errorCode?: string } |
User left or lost the call; the toolbar is no longer available. userInitiated is true when the user clicked Leave, false for an involuntary drop (in which case error/errorCode are set). |
pexip:plugin:external-chat/toggle-chat |
{ active: boolean } |
User clicked the Chat toolbar button. active is the requested state (the opposite of the current one). |
pexip:plugin:external-chat/dial-out-success |
{ uuid: string, displayName?: string } |
A dial-out request succeeded; the dialed participant joined. |
pexip:plugin:external-chat/dial-out-error |
{ message: string } |
A dial-out request failed. |
Note: Clicking the toolbar button only notifies the top window via
toggle-chat. The button's active/tooltip state does not change on its own β the top window is the source of truth and must echo backtoggle-chat-button-state(below) to update the button.
Send these from the top window to the Web App 3 iframe:
const iframe = document.querySelector('iframe#webapp3');
iframe.contentWindow.postMessage({
action: 'pexip:plugin:external-chat/toggle-chat-badge',
visible: true,
}, '*'); // use the Web App 3 origin instead of '*' in production| Action | Payload | Effect |
|---|---|---|
pexip:plugin:external-chat/toggle-chat-button-state |
{ active: boolean } |
Sets the Chat button's active state and tooltip (Close Chat / Open Chat). |
pexip:plugin:external-chat/toggle-chat-badge |
{ visible: boolean } |
Shows or hides the unread badge on the Chat button. |
pexip:plugin:external-chat/dial-out |
dial parameters (see below) | Dials a destination into the conference via conference.dialOut. Replies with dial-out-success / dial-out-error. |
These map directly to the Pexip Infinity dial request body.
| Field | Type | Required | Description |
|---|---|---|---|
role |
'HOST' | 'GUEST' |
β | Privilege level of the dialed participant. |
destination |
string |
β | The target address to call. |
protocol |
'sip' | 'h323' | 'rtmp' | 'mssip' | 'auto' |
β | Protocol used to place the call. |
call_type |
'video' | 'video-only' | 'audio' |
β | Limits the media content of the call. |
presentation_url |
string |
β | For RTMP calls, sends presentation to a separate destination. |
streaming |
'yes' | 'no' |
β | Marks the participant as a streaming/recording device. |
dtmf_sequence |
string |
β | DTMF tones to send once the call connects. |
source |
string |
β | Source URI (must be valid for the conference). |
source_display_name |
string |
β | Calling display name. |
remote_display_name |
string |
β | Friendly name shown in participant lists / overlays. |
text |
string |
β | Overlay text used instead of remote_display_name. |
keep_conference_alive |
'keep_conference_alive' | 'keep_conference_alive_if_multiple' | 'keep_conference_alive_never' |
β | Whether the conference continues after others leave. |
Example:
iframe.contentWindow.postMessage({
action: 'pexip:plugin:external-chat/dial-out',
role: 'GUEST',
destination: 'alice@example.com',
protocol: 'sip',
call_type: 'video',
remote_display_name: 'Alice',
}, '*');
conference.dialOutresolves only once the dialed participant actually joins. Hard failures (e.g. an invalid URI) reject and producedial-out-error, but a destination that simply never answers will neither resolve nor error.
Prerequisites: Node.js 18+ (developed on Node 22) and npm.
cd external-chat
npm installnpm startVite serves the plugin from https://localhost:5173 (self-signed cert via
vite-plugin-mkcert β accept it in the browser once). You access it through your
Web App 3 URL configured to load the plugin from this dev server. See the
Pexip setup guide.
npm run build # outputs to external-chat/dist/The current version is read from external-chat/package.json and is logged by the
plugin at runtime (plugin: external-chat loaded v<version>).
Run all make commands from the repository root.
make # full release: bump the minor version, build, then package
make package # build & package using the CURRENT version (no bump)| Target | What it does | Bumps version? |
|---|---|---|
make release |
Bumps the version, then runs package. |
β |
make all |
Alias for make release (the default target). |
β |
make bump |
Bumps the minor version in external-chat/package.json (npm version minor --no-git-tag-version β no git commit or tag). |
β |
make build |
Runs npm run build. |
β |
make deploy |
Runs build, then copies external-chat/dist/ into webapp3/branding/plugins/external-chat/. |
β |
make package |
Runs deploy, then zips the webapp3/ folder into external-chat-v<version>.zip at the repo root, using the current package.json version. |
β |
make clean |
Removes external-chat/dist/ and the deployed plugin folder. |
β |
Dependency chains: release β bump β package β deploy β build. Only release,
all, and bump change the version β build/deploy/package use the current
one, so CI and one-off packaging never bump (CI runs make package).
make produces external-chat-v<version>.zip in the repo root, where <version>
is the value in package.json (e.g. external-chat-v1.4.0.zip) β freshly bumped
by make/make release, or unchanged by make package. The zip contains the
full webapp3/ branding bundle, ready to upload.
.github/workflows/ci.yml runs on pushes to main, on pull requests, and on
manual dispatch. It installs dependencies (npm ci), then runs lint,
typecheck (tsc --noEmit), and make package (build + zip, no version
bump). The resulting external-chat-v<version>.zip is uploaded as a downloadable
workflow artifact named external-chat-v<version>.
.github/workflows/release.yml is a manually triggered workflow
(Actions β Release β Run workflow). It reads the current version from
external-chat/package.json, builds the zip (make package, no bump), then
creates a v<version> git tag and a GitHub release with
external-chat-v<version>.zip attached as an asset. Release notes are
auto-generated from the changes since the previous release.
Typical flow:
make bump # 1. bump the minor version in package.json
git commit -am "release" # 2. commit the bump (and push)
# 3. run the "Release" workflow from the Actions tabIf a release for the current version already exists, the workflow fails with a reminder to bump first (it never overwrites an existing tag/release).