# WordPlus Cloud docs
> The realtime cloud for websites and apps. Its realtime SDK gives channels, presence and events, calls between two people that ring in the browser, group calls, a drop-in call screen, and encryption with keys only the site or app holds. The server decides who may use each channel and call; the browser part subscribes, publishes and runs the calls. For WordPress: wordplus/realtime, for any plugin or theme. For other websites and apps: @wordplus/realtime in the browser (React and React Native too), with its server part for Node, wordplus/realtime-php for PHP and Laravel, or wordplus-realtime for Python (Django, FastAPI).
---
Source: https://docs.wordplus.cloud/
# WordPlus Cloud docs
WordPlus Cloud is the realtime cloud for websites and apps. The realtime SDK gives your code what WordPlus's own plugins run on:
- **Channels and presence:** events from your server to browsers and between browsers, and who is on a page right now.
- **Calls between two people** that ring in the browser, and **group calls** on WordPlus Cloud's media servers.
- **A drop-in call screen:** call buttons, the ring and a call window, with no call code to write.
- **Encryption and your users:** what your code sends travels sealed with keys only your site or app and its users hold, and your users share one directory of their names and pictures.
Every page connects straight to a realtime server, and moves to another without a drop when one restarts.
## WordPress
`wordplus/realtime` is the SDK for any WordPress plugin, theme or custom code on a site with a WordPlus Cloud subscription.
1. [Getting started](wordpress/getting-started.md): installing, the site's credentials, a first channel and a first call.
2. [Channels](wordpress/channels.md), [presence](wordpress/presence.md), [publishing](wordpress/publishing.md) and [client events](wordpress/client-events.md).
3. [Calls](wordpress/calls.md), [group calls](wordpress/group-calls.md) and [the call screen](wordpress/call-screen.md).
4. [Encryption and the site's users](wordpress/encryption.md).
5. The [PHP reference](wordpress/php-reference.md) and the [JavaScript reference](wordpress/javascript-reference.md).
6. [Errors](wordpress/errors.md), [limits](wordpress/limits.md) and [other servers](wordpress/self-hosted.md).
:::note In preview
The SDK runs on WordPlus Cloud today, and every WordPlus plugin carries it. Its package for your own plugins arrives on Packagist with its first release.
:::
## Websites and apps
For a site or app outside WordPress, your server decides who may listen, call and see what, and the browser does the rest. Wherever the server's code differs, these pages show it in Node, PHP and Python.
| Your server | Package |
|---|---|
| Node, Next.js, Express, Hono, edge runtimes | `@wordplus/realtime/server` |
| PHP and Laravel | `wordplus/realtime-php` |
| Python, Django, FastAPI | `wordplus-realtime` |
| The browser, React, React Native, Capacitor | `@wordplus/realtime` |
1. [Getting started](apps/getting-started.md): your app in WordPlus Cloud, installing, your server's routes, the browser, a first channel.
2. [Channels](apps/channels.md), [presence](apps/presence.md) and [publishing](apps/publishing.md).
3. [Calls, group calls and the call screen](apps/calls.md).
4. [Encryption and your users](apps/encryption.md).
5. [React, React Native and Capacitor](apps/react.md).
6. [Laravel](apps/laravel.md), [Django](apps/django.md) and [FastAPI](apps/fastapi.md).
7. The server references for [Node](apps/server.md), [PHP](apps/php-reference.md) and [Python](apps/python-reference.md), and the [browser reference](apps/browser.md).
8. [Errors](apps/errors.md) and [limits](apps/limits.md).
:::note In preview
Apps on WordPlus Cloud are switched on by request for now: [write to us](https://www.wordplus.cloud/contact) to try them. The packages arrive with their first release.
:::
## For AI coding agents
[`llms.txt`](pathname:///llms.txt) lists every page of these docs, for any tool that reads them. [Use with AI agents](ai-agents.md) has more.
---
Source: https://docs.wordplus.cloud/ai-agents/
# Use with AI agents
An AI coding agent can build realtime features into your plugin or app with the SDK, if it has the right instructions. There are three ways to give them to it, from the most complete to the simplest.
## Claude Code: the plugin
The `wordplus-realtime` plugin carries the SDK's agent skill: which feature fits a task, the steps, templates for channels, presence and calls, the rules that keep it secure, and these docs as its references. Claude Code uses it by itself whenever a task calls for live updates, presence or calls in WordPress.
Add WordPlus's marketplace once, then install the plugin:
```text
/plugin marketplace add https://packages.wordplus.cloud/claude/marketplace.json
/plugin install wordplus-realtime@wordplus
```
From your shell, the same is `claude plugin marketplace add …` and `claude plugin install wordplus-realtime@wordplus`. To get new versions as they come, turn on auto-update for the `wordplus` marketplace under **Marketplaces** in `/plugin`.
## Any agent that takes skills
The skill on its own is a folder with a `SKILL.md` and its references: [wordplus-realtime.zip](https://packages.wordplus.cloud/skills/wordplus-realtime.zip). Unzip it into your agent's skills folder: for Claude Code without the plugin, `~/.claude/skills/` for all your projects or `.claude/skills/` in one.
**For websites and apps outside WordPress**, the skill is `wordplus-realtime-apps`, and it comes inside the npm package `@wordplus/realtime`. It has templates for Node and Next.js, Laravel, Django and FastAPI, and React. Copy it into your project's skills folder once the package is installed:
```bash
cp -R node_modules/@wordplus/realtime/skills/wordplus-realtime-apps .claude/skills/
```
## Any agent: the docs as text
Every page of these docs is also plain Markdown, which an agent reads more easily than the page around it:
- **[`llms.txt`](pathname:///llms.txt)** lists every page with a line about it, as [llmstxt.org](https://llmstxt.org) describes.
- **[`llms-full.txt`](pathname:///llms-full.txt)** is all of the docs in one file. Give it to an agent that should know the whole SDK, or add it to Cursor's docs (**@Docs → Add new doc**).
- **Each page's Markdown** is at its address with `.md` in place of the last `/`, such as [`/wordpress/channels.md`](pathname:///wordpress/channels.md), and its own address answers Markdown to a request that accepts `text/markdown`. **Copy as Markdown**, at the top of every page, puts it on your clipboard for a chat.
For a project's own instructions, such as `AGENTS.md` or `CLAUDE.md`, one line is enough:
```text
WordPlus realtime SDK (wordplus/realtime): read https://docs.wordplus.cloud/llms.txt before using it.
```
Outside WordPress:
```text
WordPlus realtime SDK (@wordplus/realtime, with wordplus/realtime-php or wordplus-realtime on the server): read https://docs.wordplus.cloud/llms.txt before using it.
```
---
Source: https://docs.wordplus.cloud/wordpress/getting-started/
# Getting started
The WordPlus realtime SDK gives any WordPress plugin, theme or custom code realtime features on a site with a WordPlus Cloud subscription:
- **Channels:** events from your server to browsers, and between browsers. See [channels](channels.md), [presence](presence.md), [publishing](publishing.md) and [client events](client-events.md).
- **Calls between two people:** they ring in the browser, run directly between the two browsers, and fall back to TURN, then to a media server, when the network needs it. See [calls](calls.md).
- **Group calls:** a room on WordPlus Cloud's media servers. See [group calls](group-calls.md).
- **A drop-in call screen:** a call button, a ring and a call window, with no JavaScript to write. See [the call screen](call-screen.md).
- **Encryption and WordPress's own users:** what you send is sealed with keys only the site and its users hold, and the site's users are one directory every plugin shares. See [encryption](encryption.md).
Everything rides the one connection a page shares with WordPlus's own plugins. That connection goes straight to a realtime server and moves to another one without a drop when a server restarts.
## 1. Install
```bash
composer require wordplus/realtime
```
Composer's autoloader includes `realtime.php`. Without Composer, unzip the package into your plugin, for example into its `vendor` folder, and require its `realtime.php` from your plugin's main file. Each version's zip is on [GitHub](https://github.com/wordplus-cloud/realtime-wordpress/releases) and [packages.wordplus.cloud](https://packages.wordplus.cloud/composer/wordplus-realtime-1.0.0.zip).
WordPlus mirrors every version on its own Composer repository. To install from there instead of Packagist, run `composer config repositories.wordplus composer https://packages.wordplus.cloud/composer` first.
Every plugin may bundle its own copy. The newest copy on the site answers `wordplus_realtime()`, and the newest connector serves every page, so plugins built on different versions work side by side. Call `wordplus_realtime()` from `plugins_loaded` on.
## 2. The site's credentials
The SDK works on a site that has its WordPlus Cloud credentials: a site key and a secret. Every WordPlus plugin on the site shares them in the option `wordplus_cloud_license`.
- **A site running a WordPlus plugin** (Better Messages, for example) has them once its licence is active.
- **Any other site** connects once, from your plugin's settings page:
```php
if ( ! wordplus_realtime()->available() ) {
printf(
'%s',
esc_url( wordplus_realtime()->connect()->url( admin_url( 'admin.php?page=my-plugin' ) ) ),
esc_html__( 'Connect to WordPlus Cloud', 'my-plugin' )
);
}
```
The administrator approves in their WordPlus Cloud account, which uses one of their subscription's sites. They come back to your page with `wordplus_realtime=connected`, or `denied` or `failed`.
The secret never leaves the site's server, and it signs nothing itself. Tokens, publishes and calls each use their own key derived from it.
**Only the site's own pages connect.** A page on another address, a subdomain included, is refused with `wrong_origin`. So neither the site's key, which every page carries, nor its secret works on another website.
## 3. A first channel
Own the channels whose names start with your prefix, and say who may join them:
```php
add_action( 'plugins_loaded', function () {
wordplus_realtime()->channel( 'private-orders-', function ( $user_id, $channel ) {
$order = (int) substr( $channel, strlen( 'private-orders-' ) );
return $user_id > 0 && my_shop_customer_of( $order ) === $user_id;
} );
} );
```
Send an event from your server:
```php
wordplus_realtime()->publish( 'private-orders-42', 'updated', array( 'status' => 'paid' ) );
```
Listen in the browser. Your script depends on the SDK's handle:
```php
wp_enqueue_script( 'my-shop', plugins_url( 'shop.js', __FILE__ ), array( wordplus_realtime()->script() ), '1.0.0', true );
```
```js
// Unset on a site without its credentials: there is nothing to listen to there.
if ( window.wordplusRealtimeConfig ) {
const rt = wordplusRealtimeClient.channels( window.wordplusRealtimeConfig );
rt.subscribe( 'private-orders-42' ).on( 'updated', ( data ) => showStatus( data.status ) );
}
```
`script()` always returns a handle. On a site without credentials it returns an empty one, so your script still loads and simply finds `window.wordplusRealtimeConfig` unset.
## 4. A first call
Own the calls whose names start with your prefix:
```php
add_action( 'plugins_loaded', function () {
wordplus_realtime()->calls( 'support-', function ( $user_id, $call, $with ) {
return my_support_may_call( $user_id, $call, $with );
} );
} );
add_action( 'wp_enqueue_scripts', function () {
if ( is_user_logged_in() ) {
wordplus_realtime()->call_screen();
}
} );
```
Put a call button on a page:
```html
Call Ann
```
User 34's every open page that loads the call screen rings. When they accept, the call window opens on both sides. [calls.md](calls.md) explains the callback, and [call-screen.md](call-screen.md) the elements.
## What it needs
- **WordPlus Cloud:** a subscription that includes realtime (channels) and calls. Without them the connection is refused with `not_included`.
- **PHP** 7.4 or newer.
- **Browsers:** any current browser. Calls need WebRTC, which every current browser has.
## Next
- Look at a complete plugin: `examples/live-comments` (channels and presence) and `examples/video-support` (calls, group calls and the call screen).
- Coding with an AI agent? Point it at `skills/wordplus-realtime`.
---
Source: https://docs.wordplus.cloud/wordpress/channels/
# Channels
A channel carries events to every browser subscribed to it. Your server publishes to it ([publishing](publishing.md)), and browsers may send each other events on it ([client events](client-events.md)).
## Names and kinds
Channel names follow Pusher's rules, which many developers already know: up to 200 characters of `A-Z a-z 0-9 _ - = @ , . ;`.
| Name | Kind | Who may subscribe |
|---|---|---|
| `orders` | public | anyone with the site's key: put nothing private here |
| `private-orders-42` | private | whoever your callback lets in |
| `presence-room-7` | presence | whoever your callback lets in, listed to everyone on it ([presence](presence.md)) |
- **Each site's channels are its own.** Two sites' `orders` never meet.
- **They are separate from the Pusher-compatible API's.** A Pusher client can't join them, and the same name on both is two different channels.
- `private-encrypted-…` names are not taken.
## Owning channels
A plugin owns the channels whose names start with its prefix, and decides who may join them:
```php
add_action( 'plugins_loaded', function () {
wordplus_realtime()->channel( 'private-orders-', function ( $user_id, $channel ) {
$order = (int) substr( $channel, strlen( 'private-orders-' ) );
return $user_id > 0 && my_shop_customer_of( $order ) === $user_id;
} );
} );
```
- **`$user_id`** is the signed-in user, or 0 for a visitor.
- **The answer** is `false`, `true`, or an array. For a presence channel the array may carry `info`, which everyone on the channel sees, and `id` for a member who isn't a WordPress user ([presence](presence.md)).
- **The longest prefix wins:** register `private-orders-vip-` beside `private-orders-` and the first decides for its channels.
- **A private or presence channel nobody owns** is refused to everyone.
## Subscribing in the browser
Your script depends on the SDK's handle, and the page gets its settings in `window.wordplusRealtimeConfig`:
```php
wp_enqueue_script( 'my-shop', plugins_url( 'shop.js', __FILE__ ), array( wordplus_realtime()->script() ), '1.0.0', true );
```
```js
if ( window.wordplusRealtimeConfig ) {
const rt = wordplusRealtimeClient.channels( window.wordplusRealtimeConfig );
const order = rt.subscribe( 'private-orders-42' );
order.on( 'updated', ( data ) => showStatus( data.status ) );
order.on( 'realtime:subscribed', () => console.log( 'listening' ) );
order.on( 'realtime:error', ( e ) => console.warn( e.code ) ); // forbidden, expired, bad_token, auth_failed …
// Later:
order.unsubscribe(); // or rt.unsubscribe( 'private-orders-42' )
}
```
- **`subscribe()` returns the same subscription** for the same name, and works before the connection is up.
- **An event's listener** gets `( data, meta )`. `meta.user_id` is set for a client event, naming the user who sent it.
- **`once( event, fn )`** listens once. **`off( event, fn )`** stops listening.
- **`rt.on( 'error', fn )`** hears the connection refused, with `{ code }`: `not_included`, `not_licensed`, `wrong_origin` … ([errors](errors.md)).
- **`rt.on( 'connected' | 'disconnected' | 'moved', fn )`** follow the connection.
- **`rt.close()`** leaves every channel. The browser's other tabs keep the connection.
## The page's own events
The SDK's own events are named `realtime:…`. Your server can't publish such a name (`bad_request`), and the page drops one sent by a browser, so neither can be faked.
| Event | Gets | When |
|---|---|---|
| `realtime:subscribed` | the members, for presence | the subscription is in place, again after each reconnect |
| `realtime:error` | `{ code }` | the subscription was refused |
| `realtime:member_added` | `{ id, info }` | someone joined a presence channel |
| `realtime:member_removed` | `{ id }` | someone left it |
| `realtime:unopened` | `{ event, label }` | an event came sealed with a key the site won't give this user ([encryption](encryption.md)) |
## Sealed
What your server publishes to a private or presence channel, what browsers send each other on it, and its members' `info` travel sealed with the channel's key, which comes with the channel's token. The page opens them before your listener sees them, in the order they came. You write nothing for it; see [encryption](encryption.md).
## Tokens
A private or presence channel needs a token, which the site signs and nobody else can make:
- **The page asks the site's auth route,** `POST /wp-json/wordplus/v1/realtime/auth` with `{ channels: [...] }`. The channels subscribed in the same moment go in one request.
- **Each channel's owner answers.** The route returns one token for the channels allowed, and the reason for each refused (`denied`).
- **The token is kept and renewed** ten minutes before it runs out, so a move to another server or a reconnect never asks the site again.
- **Tokens live 6 hours** by default (the filter `wordplus_realtime_token_ttl`, at most 24 hours).
- **A function in place of the route:** pass `authorize: ( channels ) => Promise<{ token, exp, denied }>` to `channels()` to sign elsewhere.
## Moves and reconnects
The connection moves to another realtime server without a drop when one restarts. The page subscribes every channel again on the new connection, with the tokens it holds, before switching over. After a reconnect it does the same. Presence counts each user's connections, so a move never shows anyone leaving.
## One connection for the whole browser
Every tab and every plugin of a browser rides one connection to the site's channels. A tab hears only the events of its own subscriptions. An event a tab caused itself, a client event or a publish with `except` naming that tab, doesn't come back to it.
## Limits
See [limits](limits.md): 1,000 channels a connection, events of up to 10 KB, and 1,000 members a presence channel.
---
Source: https://docs.wordplus.cloud/wordpress/presence/
# Presence
A presence channel (`presence-…`) lists who is on it, to everyone on it: who is reading a post, who is in a support queue, who is typing.
## Who is a member
Your callback decides, as for a private channel ([channels](channels.md)), and also says what the others see:
```php
wordplus_realtime()->channel( 'presence-board-', function ( $user_id, $channel ) {
if ( $user_id <= 0 ) {
return false;
}
$user = get_userdata( $user_id );
return array( 'info' => array( 'name' => $user->display_name, 'avatar' => get_avatar_url( $user_id ) ) );
} );
```
- **A member is a signed-in user,** under their user id, with their display name as `info` unless your answer gives other `info`.
- **A visitor needs an id of yours:** answer `array( 'id' => 'guest-81f2', 'info' => … )`. A visitor without one is refused a presence channel (`forbidden`).
- **`info`** is any JSON up to 1 KB. Everyone on the channel sees it, so put nothing in it the channel's members shouldn't see. It travels sealed with the channel's key, so the realtime servers don't ([encryption](encryption.md)).
- **For a user's name and avatar on any page,** whatever the channel, read the site's directory with `rt.users( ids )` ([encryption](encryption.md#the-sites-users)).
## In the browser
```js
const board = rt.subscribe( 'presence-board-1' );
board.on( 'realtime:subscribed', ( members ) => drawMembers( members ) ); // [ { id, info } ]
board.on( 'realtime:member_added', ( member ) => addMember( member ) ); // { id, info }
board.on( 'realtime:member_removed', ( member ) => removeMember( member.id ) );
board.members(); // everyone on it now: [ { id, info } ]
board.memberCount; // how many, counting those not listed
```
- **The first 100 members** come with the subscription, and `memberCount` counts them all. Members who join later are added as they come.
- **A user is one member** however many tabs, devices or plugins they're on it from. `realtime:member_added` comes with their first connection, `realtime:member_removed` with their last.
- **Moves don't show anyone leaving.** When the connection moves to another server, the new connection joins before the old one leaves.
- **A server that dies** has its members taken off by the cluster within seconds.
## From your server
`channel_info()` reads a channel as the server holds it:
```php
$info = wordplus_realtime()->channel_info( 'presence-board-1' );
// array( 'ok' => true, 'subscriptions' => 3, 'members' => array( 'count' => 2, 'list' => array( array( 'id' => '7', 'info' => array( 'name' => 'Ann' ) ), … ) ) )
```
## Limits
1,000 members a presence channel; past it, a subscription is refused with `full`.
---
Source: https://docs.wordplus.cloud/wordpress/publishing/
# Publishing from your server
Your server sends events to the site's channels. Every browser subscribed to them gets each one.
```php
$sent = wordplus_realtime()->publish( 'private-orders-42', 'updated', array( 'status' => 'paid' ) );
if ( is_wp_error( $sent ) ) {
error_log( 'realtime: ' . $sent->get_error_code() ); // not_connected, not_included, bad_signature …
}
```
## `publish( $channels, $event, $data = null, $args = array() )`
- **`$channels`:** one channel name, or up to 100.
- **`$event`:** its name, up to 200 characters. A name starting with `realtime:` is refused, since those are the page's own.
- **`$data`:** anything JSON can carry, up to 10 KB.
- **`$args`:**
- `except`: the tab whose action made the event, which then doesn't get it. A browser knows its tab as `rt.tab`, and can send it with the request that triggers the publish.
- `seal`: `false` sends the data in plain. By default a private or presence channel's data goes sealed with its key, each such channel getting its own copy, and public channels' in plain ([encryption](encryption.md)).
- `blocking`: `false` sends without waiting for the answer. The answer is then always `true`.
- `timeout`: seconds to wait, 5 by default.
It returns `true`, or a `WP_Error` whose code is the server's reason ([errors](errors.md)).
```js
// The browser that made the change skips its own echo.
fetch( '/wp-json/my-shop/v1/orders/42/pay', { method: 'POST', headers: { 'X-Realtime-Tab': rt.tab } } );
```
```php
wordplus_realtime()->publish( 'private-orders-42', 'updated', $data, array( 'except' => $request->get_header( 'x_realtime_tab' ) ) );
```
## `publish_batch( $events, $args = array() )`
Up to 10 events, each an array as `publish()` takes them: `channels`, `event`, `data`, `except`, and `seal`. Sealed copies for several private channels may take more than one request.
```php
wordplus_realtime()->publish_batch( array(
array( 'channels' => 'private-orders-42', 'event' => 'updated', 'data' => array( 'status' => 'paid' ) ),
array( 'channels' => 'orders', 'event' => 'count', 'data' => array( 'open' => 12 ) ),
) );
```
## `channel_info( $channel )`
A channel as the server holds it: `subscriptions`, the connections on it, and for a presence channel its `members` (`count`, and `list` of `id` and `info`). It returns an array, or a `WP_Error`.
## How it is signed
Every request is signed as the site, with a key derived from its secret: an HMAC-SHA256 over the timestamp, the method, the path and the body's SHA-256, accepted within 5 minutes of its timestamp. The site's clock must be right within those 5 minutes, or the server answers `stale_signature`. You never handle the secret yourself.
## Where it goes
To `https://rest.wordplus.cloud/` by default. The filter `wordplus_realtime_api_server` names another server ([self-hosted](self-hosted.md)).
---
Source: https://docs.wordplus.cloud/wordpress/client-events/
# Client events
Browsers on a private or presence channel can send each other events directly, with no request to your server: typing indicators, cursors, live drawing.
```js
const board = rt.subscribe( 'presence-board-1' );
board.on( 'client-typing', ( data, { user_id } ) => showTyping( user_id, data.on ) );
let typed = 0;
input.addEventListener( 'input', () => {
if ( Date.now() - typed > 2000 ) {
typed = Date.now();
board.trigger( 'client-typing', { on: true } ).catch( ( e ) => console.warn( e.code ) );
}
} );
```
- **Names start with `client-`.** Any other is refused with `bad_event`.
- **Only on private and presence channels** the tab has subscribed to. A public channel's are refused with `forbidden`, since anyone could listen and send.
- **Everyone else on the channel gets it,** the sender's other tabs included, but not the tab that sent it.
- **`user_id`** names the sender: the user the channel's token names, on a presence channel. Your server never sees client events, so treat them as what any member could send.
- **Up to 10 a second** a browser and 10 KB each. Past it, `trigger()` rejects with `rate_limited` or `too_large`.
`trigger()` returns a promise that resolves once the server has passed the event on, or rejects with an error whose `code` says why ([errors](errors.md)).
---
Source: https://docs.wordplus.cloud/wordpress/calls/
# Calls between two people
A call rings on every page the other person has open, and runs straight between the two browsers once answered:
1. **Direct:** the two browsers connect to each other, which is the best picture and sound, at no cost.
2. **Relayed:** when a network allows no direct connection, the media goes through WordPlus Cloud's TURN servers, over UDP or over TLS on port 443.
3. **A room:** when neither works, the call moves to a room on WordPlus Cloud's media servers, on its own and without hanging up.
The [call screen](call-screen.md) does all of this with two HTML elements. This page is for building your own interface.
## Who may call whom
Your plugin owns the calls whose names start with its prefix, and decides each time someone places or answers one:
```php
add_action( 'plugins_loaded', function () {
wordplus_realtime()->calls( 'support-', function ( $user_id, $call, $with ) {
if ( null === $with ) {
return false; // a group call's room: see group-calls.md
}
$agent = my_support_agent_id();
if ( $user_id === $agent ) {
return 'support-' . $with === $call; // the agent answers a customer's call
}
return $with === $agent && 'support-' . $user_id === $call; // a customer calls the agent, on their own call
} );
} );
```
- **`$user_id`** places or answers the call: the signed-in user, or a visitor's id ([visitors](#visitors)).
- **`$call`** is the call's name, such as `support-42`, which your plugin chooses. Names follow the channels' rules: up to 200 of `A-Z a-z 0-9 _ - = @ , . ;`.
- **`$with`** is the other person: the one being called when `$user_id` places the call, the caller when `$user_id` answers. It is `null` for a [group call](group-calls.md).
- **The answer** is `false`, `true`, or an array whose `info` is what the other side sees of `$user_id`. By default that's their display name and picture: `array( 'name' => …, 'avatar' => … )`.
- **Both sides ask.** The caller's page asks when placing the call, and the callee's when answering, so your callback decides for both.
- **The longest prefix wins,** as for channels.
The site needs a plan that includes calls; without it a call ends at once, and a page's line is refused with `not_included`.
## The browser's side
Load the calls script on the pages that place or answer calls:
```php
wp_enqueue_script( 'my-support', plugins_url( 'support.js', __FILE__ ), array( wordplus_realtime()->calls_script() ), '1.0.0', true );
```
```js
// The page's calls, shared with the call screen and every other plugin on the page. Null without the site's settings.
const page = window.wordplusRealtimeCalls && wordplusRealtimeCalls.page();
if ( page ) {
const { calls } = page;
// …
}
```
`page()` makes the page's channels and calls once, from `window.wordplusRealtimeConfig`. Use it rather than `wordplusRealtimeCalls.calls( rt, config )`, so a page holds one line and a call rings once.
## Placing a call
```js
let call;
try {
call = await calls.start( 'support-42', { to: 34, type: 'video' } ); // type: 'video' or 'audio'
} catch ( e ) {
console.warn( e.code ); // forbidden: your callback said no; no_user; auth_failed …
}
call.on( 'state', ( state ) => showState( state ) ); // ringing, connecting, connected, reconnecting, ended
call.on( 'remote', ( remote ) => {
if ( remote.camera ) remote.camera.attach( document.querySelector( '#their-video' ) );
if ( remote.microphone ) remote.microphone.attach( document.querySelector( '#their-audio' ) );
} );
call.on( 'ended', ( reason ) => showEnded( reason ) );
const media = await navigator.mediaDevices.getUserMedia( { audio: true, video: true } );
call.setTrack( 'microphone', media.getAudioTracks()[ 0 ] );
call.setTrack( 'camera', media.getVideoTracks()[ 0 ] );
```
Starting the call makes it ring on every page of user 34 that holds their line. It rings for 45 seconds, then ends with `no_answer`.
## Ringing and answering
Every page that loads the calls script for a signed-in user holds that user's line, so their calls ring there:
```js
calls.on( 'incoming', ( incoming ) => {
// incoming.call: the call's name; incoming.from: the caller's id; incoming.info: what your callback gave for them;
// incoming.type: 'video' or 'audio'.
showRing( incoming );
incoming.on( 'over', ( reason ) => hideRing() ); // answered_elsewhere, rejected, busy, cancelled, no_answer
} );
// The person answers:
const call = await incoming.accept(); // the call, connecting; rejects with `forbidden` when your callback says no
// Or turns it down:
await incoming.decline(); // the caller hears `rejected`; decline( 'busy' ) and they hear `busy`
```
- **A call rings on every page of the person** at once, on every device. It stops everywhere as soon as they answer or decline on one, with `over`.
- **A page opened while a call rings** rings too.
- **`calls.incoming()`** lists the calls ringing now.
- **A page that shouldn't ring** passes `ring: false` in its settings, or calls `calls.stopRinging()`.
## The call
| Member | |
|---|---|
| `id` | this attempt at the call, made by the caller's page |
| `name`, `type`, `role` | the call's name, `video` or `audio`, and `caller` or `callee` |
| `peer` | `{ user, info }`: the other person |
| `state` | `ringing` (the caller, until answered), `connecting`, `connected`, `reconnecting`, `ended` |
| `path` | `direct`, `relay` or `room`: what carries it now |
| `remote` | the other side's tracks, `camera`, `microphone`, `screen` and `screenAudio`, each with `attach( element )` and `detach()`, `media` (what they have switched on) and `quality` (`excellent`, `good`, `poor`, `lost`) |
| `endReason` | why it ended |
| `setTrack( source, track )` | what this page sends from `camera`, `microphone`, `screen_share` or `screen_share_audio`; `null` stops sending it |
| `end()` | hangs up, or calls it off while it still rings (`cancelled`) |
Events: `state`, `path`, `remote`, `reacquire` (the other side stopped hearing this page's microphone or camera: open the device again), `autoplay_blocked` (the browser needs a click before it plays the sound), and `ended`.
**Mute** by sending `null` for the microphone and the track again to unmute. **Turn the camera off** the same way, and stop the track so the camera's light goes off. **Share a screen** with `getDisplayMedia()`, sending its video as `screen_share` and its sound as `screen_share_audio`. Each side's sources reach the other as they are, so a screen share arrives as `remote.screen` beside the camera.
## Why a call ended
| Reason | |
|---|---|
| `hangup` | a side hung up |
| `cancelled` | the caller hung up before an answer |
| `rejected`, `busy` | the callee declined, or said they were busy |
| `no_answer` | nobody answered within 45 seconds |
| `page_closed` | a side closed its page |
| `peer_lost` | a side's connection didn't come back within 45 seconds |
| `room_lost` | the room it moved to gave up |
| `not_included`, `forbidden`, `bad_token`, `expired` | the server refused to start it: the plan doesn't include calls, or the token wasn't one it takes ([errors](errors.md)) |
| `no_call`, `call_ended` | answered after the caller had given up |
| `signaling_unavailable` | the page's connection was down when it started |
## What WordPlus Cloud sees
- **Your site decides** every call: the server takes only tokens your site signed, for the people and the call it named, with a key derived from its secret.
- **The two browsers' negotiation is sealed** with a key your site derives from its own WordPress salt, which WordPlus Cloud never sees. The servers relay and store it encrypted, so they can't read the call's addresses or codecs.
- **The media** is encrypted end to end by WebRTC on a direct or relayed path. In a room, the media server forwards it between the two.
## Visitors
A visitor calls, and is called, once your plugin gives them an id: a negative number, which can't be a WordPress user's.
```php
add_filter( 'wordplus_realtime_visitor_id', function ( $id ) {
$guest = my_plugin_guest_id(); // your own, kept in a cookie
return $guest ? -1 * $guest : $id;
} );
```
## How it works
- **Lines:** each page holds its user's line on the shared connection, with a token the site signs (`POST /wp-json/wordplus/v1/realtime/calls` with `{ line: true }`).
- **Placing:** the caller's page asks the site for its token (`{ call, id, role: 'caller', to, type }`). Its first join makes the call and rings the callee's lines.
- **Answering:** the callee's page asks for its own (`{ call, id, role: 'callee', from, type }`) and joins. The server lets in only the person the caller rang, for the call it rang about.
- **The engine** is the one-to-one call engine Better Messages runs: it picks the best video codec both sides handle in hardware, restarts ICE on a network change, tries TURN twice before the room, and keeps the call through a reconnect of up to 45 seconds.
---
Source: https://docs.wordplus.cloud/wordpress/group-calls/
# Group calls
A group call is a room on WordPlus Cloud's media servers. Everyone in it sends their camera, microphone and screen once, and gets everyone else's. The servers form a mesh across regions, so a room's people may sit on different servers. A busy server sends a newcomer to another, and a full mesh holds their place while it adds a server.
The [call screen](call-screen.md)'s `` joins one with no code. This page is for building your own interface.
## Who may join
The same callback as for [calls](calls.md), with `$with` set to `null`:
```php
wordplus_realtime()->calls( 'class-', function ( $user_id, $room, $with ) {
if ( null !== $with ) {
return false; // this plugin has no calls between two people
}
$class = (int) substr( $room, strlen( 'class-' ) );
if ( my_school_teacher_of( $class ) === $user_id ) {
return array( 'admin' => true ); // the teacher runs the room
}
return my_school_student_of( $class, $user_id ) ? array( 'publish' => false ) : false; // students watch
} );
```
The array may say what the user does in the room:
| Key | Default | |
|---|---|---|
| `info` | name and picture | what the others see of them |
| `publish` | `true` | sends their camera, microphone and screen |
| `subscribe` | `true` | gets the others' |
| `admin` | `false` | may manage the room through the media server's own API |
| `hidden` | `false` | isn't listed to the others, as for a silent observer |
## Joining in the browser
```js
const { calls } = wordplusRealtimeCalls.page();
const room = await calls.room( 'class-7', { type: 'video' } ); // rejects with `forbidden` when your callback says no
room.on( 'state', ( state ) => showState( state ) ); // connecting, connected, reconnecting, ended
room.on( 'person', ( person ) => drawPerson( person ) ); // someone arrived, or what they send changed
room.on( 'person_left', ( person ) => removePerson( person.identity ) );
room.on( 'speakers', ( people ) => markSpeaking( people ) );
room.on( 'ended', ( reason ) => showEnded( reason ) );
const media = await navigator.mediaDevices.getUserMedia( { audio: true, video: true } );
await room.setTrack( 'microphone', media.getAudioTracks()[ 0 ] );
await room.setTrack( 'camera', media.getVideoTracks()[ 0 ] );
// Later:
room.leave();
```
- **`calls.room()` resolves at once** with the room connecting. Tracks set before it connects are sent once it does.
- **`room.people()`** lists everyone else in it now.
- **A person** is `{ identity, user, info, camera, microphone, screen, screenAudio, speaking }`. `user` is their user id on the site, `info` what your callback gave for them, and each track has `attach( element )` and `detach()`.
- **`room.livekit`** is the media server client's own room, for what the kit doesn't wrap: a group call's rooms are LiveKit rooms on WordPlus Cloud's own servers.
## Limits
32 people in a video room and 50 in an audio room, which the plan sets. The room's type is the one the first person joined it with.
## Where it runs
A room is `sdk__` on the mesh, apart from the site's Better Messages rooms and live streams. The page loads the media client only when it first joins a room (`realtime-rooms.min.js`, about 580 KB), so a page that never joins one never loads it.
---
Source: https://docs.wordplus.cloud/wordpress/call-screen/
# The call screen
A ready call interface for any page, with no JavaScript to write: a call button, a group call button, the ring for incoming calls, and a call window with the usual controls.
```php
add_action( 'wp_enqueue_scripts', function () {
if ( is_user_logged_in() ) {
wordplus_realtime()->call_screen();
}
} );
```
```html
Call AnnJoin the staff room
```
Who may call whom is still your plugin's to decide, in its `calls()` callback ([calls](calls.md), [group calls](group-calls.md)). The screen shows the refusal it gets.
## Where to load it
`call_screen()` enqueues the screen and everything it runs on, and returns `false` on a site without credentials. Call it while scripts are enqueued (`wp_enqueue_scripts`):
- **On the pages with the buttons,** so they work.
- **On every page a person may be called on.** A call rings only on pages that load the screen (or your own calls code). A support agent's calls ring on any page of the site they have open, when every page loads it.
Its scripts load in the footer, about 110 KB in all; the media client for rooms only when a room is joined.
## ``
A button that places a call between two people.
| Attribute | |
|---|---|
| `to` | the user id to call |
| `call` | the call's name, which your `calls()` callback owns |
| `type` | `video` (default) or `audio` |
| `name`, `avatar` | how the window shows the person called |
Its content is the button's label, `Video call` or `Audio call` when empty.
## ``
A button that joins a group call.
| Attribute | |
|---|---|
| `room` | the room's name, which your `calls()` callback owns |
| `type` | `video` (default) or `audio` |
| `title` | the window's title |
Its content is the button's label, `Join` when empty.
## The ring
When the page's user is called, a box in the corner says who calls, with Accept and Decline, and a ring tone plays (where the browser allows sound before a click). It goes as soon as the call stops ringing anywhere: answered or declined on another tab or device, called off, or unanswered. A person already in a call is busy: the caller hears so, and nothing rings.
## The call window
One on the page, in a corner, expandable to the whole window:
- **The other side's picture,** their screen share first when they share one, and their sound. Without a picture, their name and picture from `info`.
- **The page's own camera,** small, mirrored.
- **The status:** calling, connecting, reconnecting, and why it ended.
- **A timer** once connected.
- **Mute, camera, screen share and hang up.** A room has Leave in place of Hang up, and everyone on a grid, the speakers marked.
It opens the camera and the microphone when the call starts, and stops them when it ends, so the camera's light goes off with the call. Without a camera or a microphone, the call goes on with what there is.
## Making it yours
It draws in shadow roots, so a theme's styles don't reach in. Set these custom properties on the page to change its look:
```css
:root {
--wordplus-call-accent: #1a73e8; /* buttons that start or accept */
--wordplus-call-danger: #d93025; /* hang up and decline */
--wordplus-call-surface: #ffffff; /* the ring's box */
--wordplus-call-text: #1f1f1f; /* text on it */
--wordplus-call-stage: #111418; /* behind the pictures */
--wordplus-call-radius: 12px;
--wordplus-call-font: inherit;
--wordplus-call-z: 99999; /* how far above the page it sits */
}
```
The buttons expose their inner button as a `part`: `wordplus-call::part(button) { … }`.
Right-to-left pages mirror it.
## Its words
The screen's words come from `wordplus_realtime()->screen_strings()`, translated in the text domain `wordplus-realtime`. The filter `wordplus_realtime_call_screen_strings` changes any of them:
```php
add_filter( 'wordplus_realtime_call_screen_strings', function ( $strings ) {
$strings['accept'] = __( 'Answer', 'my-plugin' );
return $strings;
} );
```
`:name` in a string is the other person's name.
## From your own code
The screen is also `window.wordplusRealtimeCallScreen`:
```js
wordplusRealtimeCallScreen.place( 'support-42', 34, 'video', { name: 'Ann' } ); // what does
wordplusRealtimeCallScreen.join( 'support-staff', 'video', 'Staff room' ); // what does
```
It starts by itself once the page has loaded. The screen uses the page's calls (`wordplusRealtimeCalls.page()`), so your own code on the same page shares its line and its calls.
## Buttons without the ring
`call_screen( array( 'ring' => false ) )` loads the screen on a page that shouldn't ring: its buttons work, and the page holds no line, so the user's calls ring only on their other pages.
---
Source: https://docs.wordplus.cloud/wordpress/encryption/
# Encryption and the site's users
What your plugin sends through WordPlus Cloud is sealed on the way: the realtime servers carry it without being able to read it or change it. You don't write any encryption code. The site holds the keys, gives each user the ones they may have, and the browser part opens everything before your listener sees it.
## What is sealed
| What | Sealed with | Who can open it |
|---|---|---|
| Events your server publishes to a private or presence channel | the channel's key | whoever the channel's owner lets in |
| Client events between browsers on those channels | the channel's key | the same |
| A presence member's `info` | the channel's key | the same |
| What a call's and a room's people see of each other (`info`) | the day's key | everyone the site knows |
| The site's users in the directory (name, avatar, profile link) | the day's key | everyone the site knows |
**Public channels are not sealed.** Anyone may subscribe to them, so nothing secret belongs there anyway.
The realtime servers see user ids, which key a value needs, and the sealed values. They never hold a key.
## How it works
- **One master key on the site.** The package makes it the first time it's needed and keeps it in the option `wordplus_realtime_master_key`. It never leaves WordPress: not to WordPlus Cloud, not to a browser.
- **Every other key is derived from it,** so every plugin on the site, whichever copy of the package it carries, has the same keys. What one plugin seals, another plugin's code opens.
- **The browser gets only the keys its user may have:**
- a signed-in user's page carries their own key and the day's keys;
- a channel's key comes with the channel's token, from your channel's callback;
- anything else, the page asks the site for (`POST /wp-json/wordplus/v1/realtime/keys`), and the site checks again.
- **Keys never come from the realtime servers.** A key a server handed out could open what the browser seals next, so the browser takes keys from the site only.
- **XChaCha20-Poly1305:** a value changed on the way doesn't open. PHP seals with sodium, which every WordPress site has (WordPress carries a copy for a PHP without the extension).
## Who gets which key
| Key | Label | Given to |
|---|---|---|
| The day's | `d:{day}` | everyone the site knows: a signed-in user, or a visitor a plugin names (`wordplus_realtime_visitor_id`) |
| A user's own | `u:{id}:{generation}` | that user |
| A channel's | `ch:{channel}:{generation}` | whoever the channel's callback lets in |
| A plugin's own kind | `{kind}:{…}` | whoever that plugin's callback says |
A user the site cut off gets none (the filter `wordplus_realtime_user_has_keys`).
### When someone loses access
```php
// A customer no longer belongs to the order: what the channel carries from now on is sealed from them.
wordplus_realtime()->keys()->rotate_channel( 'private-orders-42' );
// A user's access is revoked: their own key moves on.
wordplus_realtime()->keys()->rotate_user( $user_id );
```
Those still let in get the new key from the site when its first event comes. Nothing else changes in your code.
## The site's users
Every plugin on the site shares one directory of its users on WordPlus Cloud: each user's name, avatar and profile link, sealed with the day's key. Read it in the browser:
```js
const users = await rt.users( [ 7, 12 ] );
// { 7: { user_id: 7, name: 'Ann', avatar: 'https://…', url: 'https://…/author/ann/' }, 12: { … } }
```
- **A signed-in user is in it** from their page on: the page tells the server who they are, as the site signed it, and their entry is kept for a day after they were last seen.
- **Only a page whose user the site knows may read it.** A visitor's page asks the site first; one the site doesn't know gets `no_identity`.
- **Add to an entry** with the filter `wordplus_realtime_profile`. Every text is sealed, however deep in arrays; numbers and true/false stay as they are, so put nothing private in them.
```php
add_filter( 'wordplus_realtime_profile', function ( $profile, $user_id ) {
$profile['badge'] = my_shop_badge( $user_id ); // text: sealed
return $profile;
}, 10, 2 );
```
## Sealing your own data
The keys are yours to use for anything else your plugin sends through WordPlus Cloud:
```php
$keys = wordplus_realtime()->keys();
$sealed = $keys->seal_json( $order, $keys->user_label( $customer_id ) ); // only the customer opens it
$again = json_decode( $keys->open( $sealed ), true ); // the site opens anything it sealed
```
```js
const ring = wordplusRealtimeClient.keyring();
await ring.ensure( ring.missing( sealed ) ); // asks the site for a key the page lacks
const order = ring.openJson( sealed ); // undefined when no key it holds opens it
```
A plugin with things of its own to seal, conversations or tickets, registers a kind and decides who may have its keys:
```php
wordplus_realtime()->keys()->kind( 'ticket', function ( $rest, $user_id ) {
return my_desk_may_read( (int) $rest, $user_id ); // the key ticket:42 for those who may read ticket 42
} );
```
## Turning it off
`add_filter( 'wordplus_realtime_seal', '__return_false' )` sends everything in plain, for a site that has to look at what goes by. `publish( …, array( 'seal' => false ) )` does it for one event. The browser opens what is sealed and passes on what isn't, so both work with the same code.
## The Pusher-compatible API
Plugins built for Pusher keep working as they are, and send what they send: sealing is this SDK's.
---
Source: https://docs.wordplus.cloud/wordpress/php-reference/
# PHP reference
Everything is on `wordplus_realtime()`, the newest copy of the package on the site. Call it from `plugins_loaded` on.
## Channels
| Method | Returns | |
|---|---|---|
| `channel( $prefix, $authorize )` | `$this` | owns the channels whose names start with `$prefix`. `$authorize( $user_id, $channel )` answers `false`, `true`, or `array( 'info' => …, 'id' => … )` ([channels](channels.md), [presence](presence.md)) |
| `publish( $channels, $event, $data = null, $args = array() )` | `true` or `WP_Error` | an event to up to 100 channels; `$args`: `except`, `seal`, `blocking`, `timeout` ([publishing](publishing.md)) |
| `publish_batch( $events, $args = array() )` | `true` or `WP_Error` | up to 10 events, each `channels`, `event`, `data`, `except`, `seal` |
| `channel_info( $channel )` | array or `WP_Error` | `subscriptions`, and a presence channel's `members` |
| `token( $channels, $user = null, $info = null, $ttl = null )` | string or `null` | a channels token you hand out yourself, in place of the auth route |
| `authorize( $channels, $user_id )` | array | what the auth route answers: `token`, `exp`, `keys`, `denied` |
| `may_join( $channel, $user_id )` | bool | whether the channel's owner lets the user in, as the auth route asks it |
| `script()` | handle | the page script with its settings in `window.wordplusRealtimeConfig`; an empty handle on a site without credentials |
## Calls
| Method | Returns | |
|---|---|---|
| `calls( $prefix, $authorize )` | `$this` | owns the calls and rooms whose names start with `$prefix`. `$authorize( $user_id, $call, $with )` answers `false`, `true`, or `array( 'info' => …, 'publish' => …, 'subscribe' => …, 'admin' => …, 'hidden' => … )`; `$with` is the other person, or `null` for a room ([calls](calls.md), [group calls](group-calls.md)) |
| `calls_script()` | handle | the calls script, `window.wordplusRealtimeCalls`, after the page script; the empty handle without credentials |
| `call_screen( $args = array() )` | bool | loads [the call screen](call-screen.md) on the page; `$args['ring']` false: nothing rings on it |
| `call_user()` | int | who takes part in calls on this page: `user()` |
| `authorize_call( $request, $user_id )` | array | what the calls route answers: `token`, `exp`, `key` for a call, or `denied` |
| `screen_strings()` | array | the call screen's words in the site's language |
| `WordPlus_Realtime::call_key( $id, $caller, $callee )` | string | the key a call's two pages seal their negotiation with |
## Keys and the site's users
[Encryption](encryption.md) explains them. On `wordplus_realtime()->keys()`:
| Method | Returns | |
|---|---|---|
| `seal( $value, $label )`, `seal_json( $data, $label )` | string or `null` | a value sealed with a label's key: only those who may have that key open it |
| `open( $value )` | string or `null` | what a value this site sealed holds; `null` for anything else or anything changed |
| `WordPlus_Realtime_Keys::label_of( $value )` | string or `null` | the label of the key that opens a sealed value |
| `day_label()`, `user_label( $user_id )`, `channel_label( $channel )` | string | the day's key, a user's own, a private or presence channel's newest |
| `rotate_user( $user_id )`, `rotate_channel( $channel )` | | the next key, which someone who lost access never gets |
| `kind( $kind, $may_have, $newest = null )` | `$this` | keys of your own, `{kind}:{…}`: `$may_have( $rest, $user_id, $label )` says who may have one; `$newest( $rest, $label )` names the newest of the same thing |
| `may_have( $label, $user_id )` | bool | whether the user may have a key |
| `entry( $label )` | string | a key as the site hands it to a browser |
| `user_entries( $user_id )` | string[] | the keys a user starts with: their own, and the day's from yesterday's to tomorrow's |
| `profile( $user_id )` | array or `null` | the user's entry in the site's directory, sealed (`pd`, `pdh`) |
| `sealing()` | bool | whether the site seals (`wordplus_realtime_seal`) |
And on `wordplus_realtime()`:
| Method | Returns | |
|---|---|---|
| `user()` | int | who the site knows on this request: the signed-in user, a visitor's negative id (`wordplus_realtime_visitor_id`), or 0 |
| `identity( $user_id )` | `array( 'token', 'exp' )` or `null` | who the user is for the realtime server, with their directory entry, signed as the site |
## The site
| Method | Returns | |
|---|---|---|
| `available()` | bool | the site has its credentials |
| `credentials()` | `array( 'key', 'secret' )` or `null` | the site key and the secret to sign with now |
| `site_key()` | string | the site key, or `''` |
| `connect()->url( $return_to )` | URL | WordPlus Cloud's approval, back to `$return_to` with `wordplus_realtime=connected`, `denied` or `failed` |
| `server()`, `api_server()` | URL | where browsers connect, and where the site's requests go |
| `config()` | array | what the page gets in `window.wordplusRealtimeConfig` |
| `version()` | string | this copy's version |
## Routes
| Route | |
|---|---|
| `POST /wp-json/wordplus/v1/realtime/auth` | `{ channels }` → `{ token, exp, denied }` |
| `POST /wp-json/wordplus/v1/realtime/calls` | `{ line: true }`, `{ call, id, role, to or from, type }` or `{ room, type }` → `{ token, exp, key? }` or `{ denied }` |
| `POST /wp-json/wordplus/v1/realtime/keys` | `{ labels }` → `{ keys }`, those the user may have; `{ labels: [] }` → the keys they start with and their `identity` |
Anyone may ask; your callbacks decide. A refusal is an answer, not an HTTP error.
## Filters
| Filter | Default | |
|---|---|---|
| `wordplus_realtime_token_ttl` | 21600 | a channels token's life in seconds, at most a day |
| `wordplus_realtime_call_token_ttl` | 21600 | a calls token's, at most 6 hours |
| `wordplus_realtime_visitor_id` | 0 | a visitor's id, a negative number: who takes part in calls and gets keys |
| `wordplus_realtime_seal` | true | whether what the site sends through WordPlus Cloud goes sealed |
| `wordplus_realtime_user_has_keys` | true | `false` keeps every key from a user you cut off (`$has, $user_id`) |
| `wordplus_realtime_profile` | name, avatar, author page | a user's entry in the site's directory (`$profile, $user_id`); text is sealed |
| `wordplus_realtime_call_screen_strings` | the words | the call screen's words |
| `wordplus_realtime_server` | `https://ws.wordplus.cloud/` | where browsers connect |
| `wordplus_realtime_api_server` | `https://rest.wordplus.cloud/` | where the site's requests and a closing page's leave go |
| `wordplus_realtime_account` | `https://www.wordplus.cloud/` | where Connect sends the administrator |
| `wordplus_realtime_account_api` | `https://api.wordplus.cloud/` | where Connect's code is exchanged |
## The option
The credentials live in `wordplus_cloud_license`, which every WordPlus plugin on the site shares: on a network, the network's when the site has none. The package only reads it, but Connect writes it.
The master key every other key is derived from lives in `wordplus_realtime_master_key`, the network's on a network. It never leaves the site; deleting it changes every key, and what was sealed before no longer opens.
---
Source: https://docs.wordplus.cloud/wordpress/javascript-reference/
# JavaScript reference
Three scripts, each loaded only by the pages that need it:
| Global | Script | Loaded by |
|---|---|---|
| `wordplusRealtimeClient` | the page script, which every WordPlus plugin shares | `wordplus_realtime()->script()` |
| `wordplusRealtimeCalls` | calls and group calls | `wordplus_realtime()->calls_script()` |
| `wordplusRealtimeCallScreen` | the drop-in call screen | `wordplus_realtime()->call_screen()` |
The page's settings are `window.wordplusRealtimeConfig`, unset on a site without its credentials.
## `wordplusRealtimeClient.channels( config )` → `Channels`
| Member | |
|---|---|
| `subscribe( name )` | → `Subscription`, the same one for the same name |
| `channel( name )` | → the `Subscription`, or `undefined` |
| `unsubscribe( name )` | leaves it |
| `close()` | leaves every channel |
| `connected` | whether the connection is up |
| `tab` | this tab's name on the connection, for a publish's `except` |
| `site` | the site key |
| `users( ids )` | → `Promise<{ [id]: { user_id, name, avatar, url, … } }>`: the site's users from its directory, opened; rejects with `no_identity` on a page whose user the site doesn't know ([encryption](encryption.md#the-sites-users)) |
| `keyring` | the page's keys (`Keyring`, below) |
| `on( event, fn )`, `off( event, fn? )` | `connected`, `disconnected` (reason), `error` (`{ code }`: the connection refused), `moved` (to another server, without a drop) |
`config` may also carry `authorize: ( channels ) => Promise<{ token, exp, keys, denied }>` to sign in place of the site's route.
## `wordplusRealtimeClient.keyring()` → `Keyring`
The page's keys, shared by every plugin on it ([encryption](encryption.md)). The channels open what they carry with it by themselves.
| Member | |
|---|---|
| `open( value )` | → the text a sealed value holds, `null` when no key held opens it; anything not sealed as it is |
| `openJson( value )` | → its data parsed; `undefined` when it doesn't open |
| `seal( text, label )`, `sealJson( data, label )` | → sealed with a key held, or `null` |
| `missing( value )` | → the labels of the sealed values in a payload whose keys the page lacks |
| `ensure( labels )` | → `Promise`: asks the site for those keys, once for those asked together |
| `has( label )`, `newest( prefix )`, `dayLabel()` | a key held; the newest held of one thing (`ch:private-a:`); today's |
| `onAdded( fn )` | `fn( entries, source )` for each key the page gets; returns a function that stops it |
`wordplusRealtimeClient.labelOf( value )` names the key a sealed value needs.
### `Subscription`
| Member | |
|---|---|
| `name`, `kind` | the channel, and `public`, `private` or `presence` |
| `state` | `pending`, `subscribed`, `failed`, `ended` |
| `error` | why it failed |
| `on( event, fn )`, `once( event, fn )`, `off( event?, fn? )` | a channel's event, `fn( data, { user_id } )`, opened, or the page's own: `realtime:subscribed`, `realtime:error`, `realtime:member_added`, `realtime:member_removed`, `realtime:unopened` |
| `trigger( event, data )` | → `Promise`: a `client-…` event to the channel's others ([client events](client-events.md)) |
| `members()`, `memberCount` | a presence channel's members, `[ { id, info } ]`, and how many |
| `unsubscribe()` | leaves it |
## `wordplusRealtimeCalls`
| Member | |
|---|---|
| `page()` | → `{ rt, calls }`: the page's channels and calls, made once from `wordplusRealtimeConfig`; `null` without it |
| `calls( rt, options )` | → `Calls` on a `Channels` of your own; `page()` is the one to share |
| `supportsP2P()` | whether this browser can hold a direct call |
### `Calls`
| Member | |
|---|---|
| `start( name, { to, type } )` | → `Promise`: rings `to`; rejects with `code` |
| `room( name, { type } )` | → `Promise`: joins a group call; rejects with `code` |
| `incoming()` | → the `Incoming` ringing now |
| `stopRinging()` | this page holds the user's line no more |
| `on( event, fn )`, `off( event, fn? )` | `incoming` (`Incoming`), `error` (`{ code, what }`) |
Options (in `wordplusRealtimeConfig`, or to `calls()`): `calls` (the calls route, `{ endpoint, headers }`), `authorize( request )` in its place, `rooms` (the media client's script), `api` (the server for a closing page's leave), `ring` (`false`: hold no line).
### `Incoming`
| Member | |
|---|---|
| `id`, `call`, `from`, `info`, `type` | the attempt, the call's name, the caller's id, what the site said about them, `video` or `audio` |
| `state`, `reason` | `ringing`, `answered`, `declined` or `over`, and why it's over |
| `accept()` | → `Promise` |
| `decline( reason? )` | `declined` (default) or `busy` |
| `on( 'over', fn )` | it stopped ringing: `fn( reason )` |
### `Call`
| Member | |
|---|---|
| `id`, `name`, `type`, `role`, `peer` | the attempt, the call's name, `video` or `audio`, `caller` or `callee`, `{ user, info }` |
| `state` | `ringing`, `connecting`, `connected`, `reconnecting`, `ended` |
| `path` | `direct`, `relay`, `room`, or `null` before it connects |
| `remote` | `{ camera, microphone, screen, screenAudio, media, quality }` |
| `endReason` | why it ended ([calls](calls.md#why-a-call-ended)) |
| `setTrack( source, track )` | `camera`, `microphone`, `screen_share`, `screen_share_audio`; `null` stops |
| `end()` | hangs up, or `cancelled` while ringing |
| `on( event, fn )`, `off( event, fn? )` | `state`, `path`, `remote`, `waiting`, `reacquire`, `autoplay_blocked`, `ended` |
### `Room`
| Member | |
|---|---|
| `state` | `connecting`, `waiting`, `connected`, `reconnecting`, `ended` |
| `people()` | → `Person[]`: everyone else |
| `setTrack( source, track )` | → `Promise`: what this page sends |
| `leave()` | |
| `livekit` | the media client's own room |
| `on( event, fn )`, `off( event, fn? )` | `state`, `waiting` (`{ until, reason }`), `person`, `person_left`, `speakers`, `ended` |
A `Person` is `{ identity, user, info, camera, microphone, screen, screenAudio, speaking }`.
### Remote tracks
Every track another person sends has `mediaStreamTrack`, `attach( element )`, which plays it in an `