@@ -6,6 +6,7 @@ import type {
66 RemoteDataTrack ,
77 RoomConnectOptions ,
88 RoomOptions ,
9+ RpcInvocationData ,
910 ScalabilityMode ,
1011 SimulationScenario ,
1112 VideoCaptureOptions ,
@@ -26,6 +27,7 @@ import {
2627 RemoteVideoTrack ,
2728 Room ,
2829 RoomEvent ,
30+ RpcError ,
2931 ScreenSharePresets ,
3032 Track ,
3133 TrackPublication ,
@@ -70,6 +72,19 @@ let streamReaderAbortController: AbortController | undefined;
7072let localDataTracks : Array < LocalDataTrack > = [ ] ;
7173let remoteDataTracks : Array < RemoteDataTrack > = [ ] ;
7274
75+ type RpcHandlerEntry = {
76+ topic : string ;
77+ reply : string ;
78+ invocationCount : number ;
79+ } ;
80+ const rpcHandlers : Map < string , RpcHandlerEntry > = new Map ( ) ;
81+
82+ const RPC_PRESETS = {
83+ hello : 'hello world' ,
84+ '20k' : 'X' . repeat ( 20_000 ) ,
85+ } as const ;
86+ type RpcPresetKey = keyof typeof RPC_PRESETS ;
87+
7388const searchParams = new URLSearchParams ( window . location . search ) ;
7489const storedUrl = searchParams . get ( 'url' ) ?? 'ws://localhost:7880' ;
7590const storedToken = searchParams . get ( 'token' ) ?? '' ;
@@ -824,6 +839,74 @@ const appActions = {
824839 button . removeAttribute ( 'disabled' ) ;
825840 }
826841 } ,
842+
843+ fillRpcPreset : ( inputId : string , key : RpcPresetKey ) => {
844+ const input = < HTMLInputElement > $ ( inputId ) ;
845+ input . value = RPC_PRESETS [ key ] ;
846+ input . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
847+ } ,
848+
849+ sendRpc : async ( ) => {
850+ const destinationIdentity = ( < HTMLSelectElement > $ ( 'rpc-send-destination' ) ) . value ;
851+ const method = ( < HTMLInputElement > $ ( 'rpc-send-topic' ) ) . value . trim ( ) ;
852+ const payload = ( < HTMLInputElement > $ ( 'rpc-send-payload' ) ) . value ;
853+ const result = $ ( 'rpc-send-result' ) ;
854+ const button = < HTMLButtonElement > $ ( 'rpc-send-button' ) ;
855+ if ( ! currentRoom ) {
856+ result . textContent = '✕ Not connected' ;
857+ result . className = 'text-monospace mt-1 text-danger' ;
858+ return ;
859+ }
860+ if ( ! destinationIdentity ) {
861+ result . textContent = '✕ Choose a destination' ;
862+ result . className = 'text-monospace mt-1 text-danger' ;
863+ return ;
864+ }
865+ if ( ! method ) {
866+ result . textContent = '✕ Topic is required' ;
867+ result . className = 'text-monospace mt-1 text-danger' ;
868+ return ;
869+ }
870+ result . textContent = `→ ${ destinationIdentity } ${ method } ...` ;
871+ result . className = 'text-monospace mt-1 text-muted' ;
872+ button . disabled = true ;
873+ try {
874+ const response = await currentRoom . localParticipant . performRpc ( {
875+ destinationIdentity,
876+ method,
877+ payload,
878+ } ) ;
879+ const preview =
880+ response . length > 20 ? `${ response . slice ( 0 , 20 ) } ... (${ response . length } B)` : response ;
881+ result . textContent = `✓ ${ preview } ` ;
882+ result . className = 'text-monospace mt-1 text-success' ;
883+ } catch ( err ) {
884+ const msg = err instanceof RpcError ? `${ err . code } : ${ err . message } ` : String ( err ) ;
885+ result . textContent = `✕ ${ msg } ` ;
886+ result . className = 'text-monospace mt-1 text-danger' ;
887+ } finally {
888+ button . disabled = false ;
889+ }
890+ } ,
891+
892+ registerRpcHandler : ( ) => {
893+ const topicInput = < HTMLInputElement > $ ( 'rpc-handler-topic' ) ;
894+ const topic = topicInput . value . trim ( ) ;
895+ if ( ! currentRoom || ! topic || rpcHandlers . has ( topic ) ) {
896+ topicInput . classList . add ( 'is-invalid' ) ;
897+ return ;
898+ }
899+ topicInput . classList . remove ( 'is-invalid' ) ;
900+ const entry : RpcHandlerEntry = { topic, reply : '' , invocationCount : 0 } ;
901+ rpcHandlers . set ( topic , entry ) ;
902+ currentRoom . registerRpcMethod ( topic , async ( data : RpcInvocationData ) => {
903+ entry . invocationCount += 1 ;
904+ appendRpcInvocation ( topic , entry . invocationCount , data . callerIdentity , data . payload ) ;
905+ return entry . reply ;
906+ } ) ;
907+ topicInput . value = '' ;
908+ renderRpcHandlers ( ) ;
909+ } ,
827910} ;
828911
829912declare global {
@@ -878,12 +961,14 @@ async function participantConnected(participant: Participant) {
878961 . on ( ParticipantEvent . ConnectionQualityChanged , ( ) => {
879962 renderParticipant ( participant ) ;
880963 } ) ;
964+ refreshRpcDestinations ( ) ;
881965}
882966
883967function participantDisconnected ( participant : RemoteParticipant ) {
884968 appendLog ( 'participant' , participant . sid , 'disconnected' ) ;
885969
886970 renderParticipant ( participant , true ) ;
971+ refreshRpcDestinations ( ) ;
887972}
888973
889974function handleRoomDisconnect ( reason ?: DisconnectReason ) {
@@ -902,6 +987,11 @@ function handleRoomDisconnect(reason?: DisconnectReason) {
902987 remoteDataTracks = [ ] ;
903988 renderRemoteDataTracks ( ) ;
904989
990+ rpcHandlers . clear ( ) ;
991+ renderRpcHandlers ( ) ;
992+ $ ( 'rpc-send-result' ) . textContent = '' ;
993+ refreshRpcDestinations ( ) ;
994+
905995 const container = $ ( 'participants-area' ) ;
906996 if ( container ) {
907997 container . innerHTML = '' ;
@@ -1502,6 +1592,162 @@ function renderRemoteDataTracks() {
15021592 }
15031593}
15041594
1595+ function createRpcHandlerElement ( topic : string ) : HTMLElement {
1596+ const item = document . createElement ( 'div' ) ;
1597+ item . className = 'list-group-item local-data-track-item p-2 mt-2' ;
1598+ item . dataset . topic = topic ;
1599+
1600+ const safeId = encodeURIComponent ( topic ) . replace ( / % / g, '_' ) ;
1601+ const replyInputId = `rpc-handler-reply-${ safeId } ` ;
1602+ const unregisterId = `rpc-handler-unregister-${ safeId } ` ;
1603+
1604+ item . innerHTML = `
1605+ <div class="d-flex align-items-start justify-content-between mt-1">
1606+ <span class="font-weight-bold text-truncate mr-2" title="${ topic } ">${ topic } </span>
1607+ <button id="${ unregisterId } " class="btn btn-outline-danger btn-sm" type="button">✕</button>
1608+ </div>
1609+ <div class="input-group input-group-sm mt-2">
1610+ <input
1611+ id="${ replyInputId } "
1612+ type="text"
1613+ class="form-control text-monospace"
1614+ placeholder="Reply payload"
1615+ />
1616+ <div class="input-group-append">
1617+ <button class="btn btn-outline-secondary rpc-handler-preset-hello" type="button">Hello</button>
1618+ <button class="btn btn-outline-secondary rpc-handler-preset-20k" type="button">20k</button>
1619+ </div>
1620+ </div>
1621+ <div
1622+ class="rpc-handler-well bg-dark rounded p-2 text-monospace mt-2"
1623+ style="max-height: 180px; overflow-y: auto; font-size: 0.7rem; min-height: 60px; display: flex; align-items: center; justify-content: center;"
1624+ >
1625+ <span class="rpc-handler-placeholder text-muted">No invocations yet</span>
1626+ </div>
1627+ ` ;
1628+
1629+ const replyInput = item . querySelector < HTMLInputElement > ( `#${ replyInputId } ` ) ! ;
1630+ replyInput . addEventListener ( 'input' , ( ) => {
1631+ const entry = rpcHandlers . get ( topic ) ;
1632+ if ( entry ) {
1633+ entry . reply = replyInput . value ;
1634+ }
1635+ } ) ;
1636+
1637+ item
1638+ . querySelector < HTMLButtonElement > ( '.rpc-handler-preset-hello' ) !
1639+ . addEventListener ( 'click' , ( ) => {
1640+ replyInput . value = RPC_PRESETS . hello ;
1641+ const entry = rpcHandlers . get ( topic ) ;
1642+ if ( entry ) {
1643+ entry . reply = replyInput . value ;
1644+ }
1645+ } ) ;
1646+ item
1647+ . querySelector < HTMLButtonElement > ( '.rpc-handler-preset-20k' ) !
1648+ . addEventListener ( 'click' , ( ) => {
1649+ replyInput . value = RPC_PRESETS [ '20k' ] ;
1650+ const entry = rpcHandlers . get ( topic ) ;
1651+ if ( entry ) {
1652+ entry . reply = replyInput . value ;
1653+ }
1654+ } ) ;
1655+
1656+ const unregisterBtn = item . querySelector < HTMLButtonElement > ( `#${ unregisterId } ` ) ! ;
1657+ unregisterBtn . addEventListener ( 'click' , ( ) => {
1658+ if ( currentRoom ) {
1659+ currentRoom . unregisterRpcMethod ( topic ) ;
1660+ }
1661+ rpcHandlers . delete ( topic ) ;
1662+ renderRpcHandlers ( ) ;
1663+ } ) ;
1664+
1665+ return item ;
1666+ }
1667+
1668+ function renderRpcHandlers ( ) {
1669+ const wrapper = $ ( 'rpc-handlers-list' ) ;
1670+ const rendered = new Set < string > ( ) ;
1671+
1672+ for ( const child of Array . from ( wrapper . children ) ) {
1673+ const el = child as HTMLElement ;
1674+ const topic = el . dataset . topic ! ;
1675+ if ( ! rpcHandlers . has ( topic ) ) {
1676+ el . remove ( ) ;
1677+ } else {
1678+ rendered . add ( topic ) ;
1679+ }
1680+ }
1681+
1682+ for ( const topic of rpcHandlers . keys ( ) ) {
1683+ if ( ! rendered . has ( topic ) ) {
1684+ wrapper . appendChild ( createRpcHandlerElement ( topic ) ) ;
1685+ }
1686+ }
1687+ }
1688+
1689+ function appendRpcInvocation ( topic : string , n : number , caller : string , payload : string ) : void {
1690+ const card = $ ( 'rpc-handlers-list' ) . querySelector < HTMLElement > (
1691+ `[data-topic="${ CSS . escape ( topic ) } "]` ,
1692+ ) ;
1693+ if ( ! card ) return ;
1694+ const well = card . querySelector < HTMLElement > ( '.rpc-handler-well' ) ! ;
1695+ const placeholder = well . querySelector ( '.rpc-handler-placeholder' ) ;
1696+ if ( placeholder ) {
1697+ placeholder . remove ( ) ;
1698+ well . style . display = 'block' ;
1699+ }
1700+
1701+ const entry = document . createElement ( 'div' ) ;
1702+ entry . className = 'border-bottom border-secondary pb-1 mb-1' ;
1703+
1704+ const sizeString =
1705+ payload . length < 1024 ? `${ payload . length } B` : `${ ( payload . length / 1024 ) . toFixed ( 2 ) } KB` ;
1706+
1707+ const meta = document . createElement ( 'div' ) ;
1708+ meta . className = 'text-muted' ;
1709+ meta . style . cssText = 'font-size: 0.65rem;' ;
1710+ meta . textContent = `#${ n } · ${ caller } · ${ sizeString } · ${ new Date ( ) . toISOString ( ) } ` ;
1711+ entry . appendChild ( meta ) ;
1712+
1713+ const body = document . createElement ( 'div' ) ;
1714+ body . style . cssText = 'white-space: pre-wrap; word-break: break-word; color: #e9ecef;' ;
1715+ body . textContent = payload ;
1716+ entry . appendChild ( body ) ;
1717+
1718+ well . appendChild ( entry ) ;
1719+ well . scrollTop = well . scrollHeight ;
1720+ }
1721+
1722+ function refreshRpcDestinations ( ) : void {
1723+ const select = < HTMLSelectElement > $ ( 'rpc-send-destination' ) ;
1724+ const previous = select . value ;
1725+
1726+ const identities : string [ ] = [ ] ;
1727+ if ( currentRoom ) {
1728+ currentRoom . remoteParticipants . forEach ( ( p ) => identities . push ( p . identity ) ) ;
1729+ }
1730+ identities . sort ( ) ;
1731+
1732+ select . innerHTML = '' ;
1733+ if ( identities . length === 0 ) {
1734+ const opt = document . createElement ( 'option' ) ;
1735+ opt . value = '' ;
1736+ opt . textContent = '(no remote participants)' ;
1737+ select . appendChild ( opt ) ;
1738+ } else {
1739+ for ( const identity of identities ) {
1740+ const opt = document . createElement ( 'option' ) ;
1741+ opt . value = identity ;
1742+ opt . textContent = identity ;
1743+ select . appendChild ( opt ) ;
1744+ }
1745+ if ( identities . includes ( previous ) ) {
1746+ select . value = previous ;
1747+ }
1748+ }
1749+ }
1750+
15051751function getParticipantsAreaElement ( ) : HTMLElement {
15061752 return (
15071753 window . documentPictureInPicture ?. window ?. document . querySelector ( '#participants-area' ) ||
0 commit comments