From 60e45ab44e5ef81642b74d2a9e9e21e26ab675b8 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 14 Sep 2026 22:02:13 -0700 Subject: [PATCH 1/2] feat(rtc): add RPC interceptors Port of livekit/python-sdks#806. An RpcInterceptor wraps every RPC the local participant performs (interceptOutgoing) or handles (interceptIncoming), so telemetry can trace calls in both directions without touching user code. Interceptors run in registration order, the first outermost; errors from the remote side or from the handler flow through the chain unchanged, and a call for an unregistered method reaches the chain with `next` throwing UNSUPPORTED_METHOD, so an interceptor can record that the agent never registered it. RpcInvocationData now carries the invoked `method`, and RpcCallInfo mirrors performRpc's parameters so an interceptor can hand a modified call to `next`. Deliberate deviation from the Python SDK: the Node SDK never enforced the caller's responseTimeout on the handler locally (the caller times out on its side), and promises cannot be cancelled, so the incoming chain is not raced against a deadline here. Co-Authored-By: Claude Fable 5.1 --- .changeset/rpc-interceptors.md | 5 + packages/livekit-rtc/README.md | 40 ++++- packages/livekit-rtc/src/index.ts | 10 +- packages/livekit-rtc/src/participant.ts | 94 ++++++++--- packages/livekit-rtc/src/rpc.test.ts | 177 +++++++++++++++++++++ packages/livekit-rtc/src/rpc.ts | 116 ++++++++++++++ packages/livekit-rtc/src/tests/e2e.test.ts | 32 ++++ 7 files changed, 450 insertions(+), 24 deletions(-) create mode 100644 .changeset/rpc-interceptors.md create mode 100644 packages/livekit-rtc/src/rpc.test.ts diff --git a/.changeset/rpc-interceptors.md b/.changeset/rpc-interceptors.md new file mode 100644 index 00000000..55e515dd --- /dev/null +++ b/.changeset/rpc-interceptors.md @@ -0,0 +1,5 @@ +--- +'@livekit/rtc-node': minor +--- + +Add `RpcInterceptor` support: `LocalParticipant.addRpcInterceptor()` wraps every RPC the participant performs or handles, for logging, tracing, or payload metadata. `RpcInvocationData` now carries the invoked `method`. diff --git a/packages/livekit-rtc/README.md b/packages/livekit-rtc/README.md index 0e25dfa3..17a873cd 100644 --- a/packages/livekit-rtc/README.md +++ b/packages/livekit-rtc/README.md @@ -79,7 +79,7 @@ await track.close(); ### RPC -Perform your own predefined method calls from one participant to another. +Perform your own predefined method calls from one participant to another. This feature is especially powerful when used with [Agents](https://docs.livekit.io/agents), for instance to forward LLM function calls to your client application. @@ -89,14 +89,14 @@ The participant who implements the method and will receive its calls must first ```typescript room.localParticipant?.registerRpcMethod( - // method name - can be any string that makes sense for your application + // method name - can be any string that makes sense for your application 'greet', // method handler - will be called when the method is invoked by a RemoteParticipant async (data: RpcInvocationData) => { console.log(`Received greeting from ${data.callerIdentity}: ${data.payload}`); return `Hello, ${data.callerIdentity}!`; - } + }, ); ``` @@ -121,9 +121,40 @@ try { You may find it useful to adjust the `responseTimeout` parameter, which indicates the amount of time you will wait for a response. We recommend keeping this value as low as possible while still satisfying the constraints of your application. +#### Intercepting RPC calls + +An `RpcInterceptor` wraps every RPC the local participant performs or handles, which is useful for logging, tracing, or attaching metadata to payloads. Each method receives the call and a `next` continuation; return what `next` returns. Interceptors run in the order they were added, the first being outermost, and errors from the remote side or from your handler flow through them unchanged. Implement only the direction you care about. + +```typescript +const timing: RpcInterceptor = { + async interceptOutgoing(call, next) { + const start = performance.now(); + try { + return await next(call); + } finally { + console.log( + `call ${call.method} -> ${call.destinationIdentity}: ${performance.now() - start}ms`, + ); + } + }, + async interceptIncoming(invocation, next) { + const start = performance.now(); + try { + return await next(invocation); + } finally { + console.log( + `handled ${invocation.method} from ${invocation.callerIdentity}: ${performance.now() - start}ms`, + ); + } + }, +}; + +room.localParticipant!.addRpcInterceptor(timing); +``` + #### Errors -LiveKit is a dynamic realtime environment and calls can fail for various reasons. +LiveKit is a dynamic realtime environment and calls can fail for various reasons. You may throw errors of the type `RpcError` with a string `message` in an RPC method handler and they will be received on the caller's side with the message intact. Other errors will not be transmitted and will instead arrive to the caller as `1500` ("Application Error"). Other built-in errors are detailed in `RpcError`. @@ -132,7 +163,6 @@ You may throw errors of the type `RpcError` with a string `message` in an RPC me - [`publish-wav`](https://github.com/livekit/node-sdks/tree/main/examples/publish-wav): connect to a room and publish a .wave file - [`rpc`](https://github.com/livekit/node-sdks/tree/main/examples/rpc): simple back-and-forth RPC interaction - ## Getting help / Contributing Please join us on [Slack](https://livekit.io/join-slack) to get help from our devs & community. We welcome your contributions and details can be discussed there. diff --git a/packages/livekit-rtc/src/index.ts b/packages/livekit-rtc/src/index.ts index 50728c46..4a3020b2 100644 --- a/packages/livekit-rtc/src/index.ts +++ b/packages/livekit-rtc/src/index.ts @@ -44,7 +44,15 @@ export { type RoomOptions, type RtcConfiguration, } from './room.js'; -export { RpcError, type PerformRpcParams, type RpcInvocationData } from './rpc.js'; +export { + RpcError, + type IncomingRpcNext, + type OutgoingRpcNext, + type PerformRpcParams, + type RpcCallInfo, + type RpcInterceptor, + type RpcInvocationData, +} from './rpc.js'; export { LocalAudioTrack, LocalVideoTrack, diff --git a/packages/livekit-rtc/src/participant.ts b/packages/livekit-rtc/src/participant.ts index af60164a..4a6bc974 100644 --- a/packages/livekit-rtc/src/participant.ts +++ b/packages/livekit-rtc/src/participant.ts @@ -90,7 +90,15 @@ import { } from './data_streams/index.js'; import { FfiClient, FfiHandle } from './ffi_client.js'; import { log } from './log.js'; -import { type PerformRpcParams, RpcError, type RpcInvocationData } from './rpc.js'; +import { + type PerformRpcParams, + type RpcCallInfo, + RpcError, + type RpcInterceptor, + type RpcInvocationData, + chainIncoming, + chainOutgoing, +} from './rpc.js'; import type { LocalTrack } from './track.js'; import type { RemoteTrackPublication, TrackPublication } from './track_publication.js'; import { LocalTrackPublication } from './track_publication.js'; @@ -167,6 +175,7 @@ export type DataPublishOptions = { export class LocalParticipant extends Participant { private rpcHandlers: Map Promise> = new Map(); + private rpcInterceptors: RpcInterceptor[] = []; private ffiEventLock: Mutex; @@ -833,6 +842,18 @@ export class LocalParticipant extends Participant { payload, responseTimeout, }: PerformRpcParams): Promise { + const call: RpcCallInfo = { destinationIdentity, method, payload, responseTimeout }; + // snapshot the interceptor list so add/remove during a call is well defined + const perform = chainOutgoing([...this.rpcInterceptors], (c) => this.performRpcFfi(c)); + return await perform(call); + } + + private async performRpcFfi({ + destinationIdentity, + method, + payload, + responseTimeout, + }: RpcCallInfo): Promise { const req = new PerformRpcRequest({ localParticipantHandle: this.ffi_handle.handle, destinationIdentity, @@ -857,6 +878,29 @@ export class LocalParticipant extends Participant { return cb.payload!; } + /** + * Add an {@link RpcInterceptor} that wraps every RPC this participant performs or handles. + * Interceptors run in the order they were added, the first being outermost. Adding the same + * instance twice is a no-op. + * + * @param interceptor - The interceptor to add + */ + addRpcInterceptor(interceptor: RpcInterceptor) { + if (!this.rpcInterceptors.includes(interceptor)) { + this.rpcInterceptors.push(interceptor); + } + } + + /** + * Remove a previously added {@link RpcInterceptor}. Calls already in flight keep the chain + * they started with. + * + * @param interceptor - The interceptor to remove + */ + removeRpcInterceptor(interceptor: RpcInterceptor) { + this.rpcInterceptors = this.rpcInterceptors.filter((existing) => existing !== interceptor); + } + /** * Establishes the participant as a receiver for calls of the specified RPC method. * Will overwrite any existing callback for the same method. @@ -928,23 +972,28 @@ export class LocalParticipant extends Participant { let responseError: RpcError | null = null; let responsePayload: string | null = null; - const handler = this.rpcHandlers.get(method); - - if (!handler) { - responseError = RpcError.builtIn('UNSUPPORTED_METHOD'); - } else { - try { - responsePayload = await handler({ requestId, callerIdentity, payload, responseTimeout }); - } catch (error) { - if (error instanceof RpcError) { - responseError = error; - } else { - console.warn( - `Uncaught error returned by RPC handler for ${method}. Returning APPLICATION_ERROR instead.`, - error, - ); - responseError = RpcError.builtIn('APPLICATION_ERROR'); - } + const invocation: RpcInvocationData = { + requestId, + callerIdentity, + payload, + responseTimeout, + method, + }; + // the chain sees the handler's outcome unchanged, including UNSUPPORTED_METHOD for a method + // nothing is registered for; only after it settles is a non-RpcError turned into + // APPLICATION_ERROR for the caller + const handle = chainIncoming([...this.rpcInterceptors], (inv) => this.invokeRpcHandler(inv)); + try { + responsePayload = (await handle(invocation)) ?? null; + } catch (error) { + if (error instanceof RpcError) { + responseError = error; + } else { + console.warn( + `Uncaught error returned by RPC handler for ${method}. Returning APPLICATION_ERROR instead.`, + error, + ); + responseError = RpcError.builtIn('APPLICATION_ERROR'); } } @@ -963,6 +1012,15 @@ export class LocalParticipant extends Participant { console.warn(`error sending rpc method invocation response: ${res.error}`); } } + + /** The innermost step of the incoming chain: run the registered handler, if any. */ + private async invokeRpcHandler(invocation: RpcInvocationData): Promise { + const handler = this.rpcHandlers.get(invocation.method); + if (!handler) { + throw RpcError.builtIn('UNSUPPORTED_METHOD'); + } + return await handler(invocation); + } } export class RemoteParticipant extends Participant { diff --git a/packages/livekit-rtc/src/rpc.test.ts b/packages/livekit-rtc/src/rpc.test.ts new file mode 100644 index 00000000..357bdcbc --- /dev/null +++ b/packages/livekit-rtc/src/rpc.test.ts @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { + type RpcCallInfo, + RpcError, + type RpcInterceptor, + type RpcInvocationData, + chainIncoming, + chainOutgoing, +} from './rpc.js'; + +const call: RpcCallInfo = { + destinationIdentity: 'callee', + method: 'greet', + payload: 'hi', + responseTimeout: 1000, +}; + +const invocation: RpcInvocationData = { + requestId: 'req-1', + callerIdentity: 'caller', + payload: 'hi', + responseTimeout: 1000, + method: 'greet', +}; + +/** An interceptor that records when it runs, in both directions. */ +function recording(name: string, log: string[]): RpcInterceptor { + return { + async interceptOutgoing(c, next) { + log.push(`${name}:out:before`); + try { + return await next(c); + } finally { + log.push(`${name}:out:after`); + } + }, + async interceptIncoming(inv, next) { + log.push(`${name}:in:before`); + try { + return await next(inv); + } finally { + log.push(`${name}:in:after`); + } + }, + }; +} + +describe('rpc interceptor chains', () => { + it('runs interceptors in registration order, the first outermost', async () => { + const log: string[] = []; + const perform = chainOutgoing([recording('a', log), recording('b', log)], async (c) => { + log.push(`terminal:${c.method}`); + return 'pong'; + }); + await expect(perform(call)).resolves.toBe('pong'); + expect(log).toEqual([ + 'a:out:before', + 'b:out:before', + 'terminal:greet', + 'b:out:after', + 'a:out:after', + ]); + + log.length = 0; + const handle = chainIncoming([recording('a', log), recording('b', log)], async (inv) => { + log.push(`terminal:${inv.method}`); + return 'pong'; + }); + await expect(handle(invocation)).resolves.toBe('pong'); + expect(log).toEqual([ + 'a:in:before', + 'b:in:before', + 'terminal:greet', + 'b:in:after', + 'a:in:after', + ]); + }); + + it('passes through interceptors that do not implement a direction', async () => { + const seen: string[] = []; + const outgoingOnly: RpcInterceptor = { + async interceptOutgoing(c, next) { + seen.push('outgoing'); + return next(c); + }, + }; + await expect(chainOutgoing([outgoingOnly, {}], async () => 'ok')(call)).resolves.toBe('ok'); + await expect(chainIncoming([outgoingOnly, {}], async () => 'ok')(invocation)).resolves.toBe( + 'ok', + ); + expect(seen).toEqual(['outgoing']); + }); + + it('is the terminal alone when there are no interceptors', async () => { + const terminal = async () => 'ok'; + expect(chainOutgoing([], terminal)).toBe(terminal); + expect(chainIncoming([], terminal)).toBe(terminal); + }); + + it('lets an interceptor hand a modified call to the next step', async () => { + const addHeader: RpcInterceptor = { + async interceptOutgoing(c, next) { + return next({ ...c, payload: JSON.stringify({ traceparent: 'abc', body: c.payload }) }); + }, + }; + let received: RpcCallInfo | undefined; + await chainOutgoing([addHeader], async (c) => { + received = c; + return ''; + })(call); + expect(received).toEqual({ ...call, payload: '{"traceparent":"abc","body":"hi"}' }); + }); + + it('lets an interceptor short-circuit the call', async () => { + let terminalRan = false; + const cached: RpcInterceptor = { + async interceptOutgoing() { + return 'from-cache'; + }, + }; + await expect( + chainOutgoing([cached], async () => { + terminalRan = true; + return 'from-remote'; + })(call), + ).resolves.toBe('from-cache'); + expect(terminalRan).toBe(false); + }); + + it('propagates errors from the terminal through every interceptor unchanged', async () => { + const seen: unknown[] = []; + const observe: RpcInterceptor = { + async interceptIncoming(inv, next) { + try { + return await next(inv); + } catch (error) { + seen.push(error); + throw error; + } + }, + }; + const unsupported = RpcError.builtIn('UNSUPPORTED_METHOD'); + await expect( + chainIncoming([observe, observe], async () => { + throw unsupported; + })(invocation), + ).rejects.toBe(unsupported); + // both layers saw the same RpcError, with its built-in code intact + expect(seen).toEqual([unsupported, unsupported]); + expect((seen[0] as RpcError).code).toBe(RpcError.ErrorCode.UNSUPPORTED_METHOD); + + const boom = new Error('handler exploded'); + await expect( + chainIncoming([observe], async () => { + throw boom; + })(invocation), + ).rejects.toBe(boom); + }); + + it('binds `this` so class-based interceptors can use their own state', async () => { + class Counter implements RpcInterceptor { + calls = 0; + async interceptOutgoing(c: RpcCallInfo, next: (c: RpcCallInfo) => Promise) { + this.calls++; + return next(c); + } + } + const counter = new Counter(); + const perform = chainOutgoing([counter], async () => 'ok'); + await perform(call); + await perform(call); + expect(counter.calls).toBe(2); + }); +}); diff --git a/packages/livekit-rtc/src/rpc.ts b/packages/livekit-rtc/src/rpc.ts index 5682aa34..8dcabb5b 100644 --- a/packages/livekit-rtc/src/rpc.ts +++ b/packages/livekit-rtc/src/rpc.ts @@ -38,6 +38,122 @@ export interface RpcInvocationData { * The maximum time the caller will wait for a response. */ responseTimeout: number; + + /** + * The name of the invoked RPC method. + */ + method: string; +} + +/** + * An outgoing RPC call, as passed to {@link RpcInterceptor.interceptOutgoing}. + * + * Mirrors the parameters of `LocalParticipant.performRpc`. An interceptor may pass a modified + * copy to `next` (for example to add a header to a JSON payload). + */ +export interface RpcCallInfo { + /** The identity of the participant being called. */ + destinationIdentity: string; + /** The method name. */ + method: string; + /** The request payload. */ + payload: string; + /** Milliseconds to wait for a response, or `undefined` for the default. */ + responseTimeout?: number; +} + +/** Continuation handed to {@link RpcInterceptor.interceptOutgoing}: performs the call. */ +export type OutgoingRpcNext = (call: RpcCallInfo) => Promise; + +/** Continuation handed to {@link RpcInterceptor.interceptIncoming}: runs the handler. */ +export type IncomingRpcNext = (invocation: RpcInvocationData) => Promise; + +/** + * Observe or wrap RPC calls made and handled by a `LocalParticipant`. + * + * Register with `LocalParticipant.addRpcInterceptor`. Each method receives the call and a `next` + * continuation and must return (or throw) what `next` returns (or throws), unless it + * deliberately short-circuits the call. Interceptors run in registration order: the first one + * added is the outermost. Both methods are optional, so implement only the direction you care + * about. + * + * Errors flow through the chain unchanged: an {@link RpcError} thrown by the remote side + * (outgoing) or by the handler (incoming) is visible to every interceptor before it reaches the + * caller. On the incoming side, any other error thrown by the handler is also visible; the SDK + * converts it to `APPLICATION_ERROR` only after the chain settles, and a call for an + * unregistered method reaches the chain with `next` throwing `UNSUPPORTED_METHOD`. + * + * @example + * Time every RPC in both directions: + * ```typescript + * const timing: RpcInterceptor = { + * async interceptOutgoing(call, next) { + * const start = performance.now(); + * try { + * return await next(call); + * } finally { + * console.log(`call ${call.method} -> ${call.destinationIdentity}: ${performance.now() - start}ms`); + * } + * }, + * async interceptIncoming(invocation, next) { + * const start = performance.now(); + * try { + * return await next(invocation); + * } finally { + * console.log(`handled ${invocation.method} from ${invocation.callerIdentity}: ${performance.now() - start}ms`); + * } + * }, + * }; + * room.localParticipant!.addRpcInterceptor(timing); + * ``` + */ +export interface RpcInterceptor { + /** Wrap an outgoing `LocalParticipant.performRpc`. Return the response payload. */ + interceptOutgoing?(call: RpcCallInfo, next: OutgoingRpcNext): Promise; + /** Wrap the handling of an incoming invocation. Return the response payload. */ + interceptIncoming?(invocation: RpcInvocationData, next: IncomingRpcNext): Promise; +} + +/** + * Compose `interceptors` around `terminal`; the first interceptor is outermost. Interceptors + * without an `interceptOutgoing` method are pass-through. + * + * @internal + */ +export function chainOutgoing( + interceptors: readonly RpcInterceptor[], + terminal: OutgoingRpcNext, +): OutgoingRpcNext { + let callNext = terminal; + for (let i = interceptors.length - 1; i >= 0; i--) { + const interceptor = interceptors[i]!; + const intercept = interceptor.interceptOutgoing; + if (!intercept) continue; + const inner = callNext; + callNext = (call) => intercept.call(interceptor, call, inner); + } + return callNext; +} + +/** + * Compose `interceptors` around `terminal`; the first interceptor is outermost. Interceptors + * without an `interceptIncoming` method are pass-through. + * + * @internal + */ +export function chainIncoming( + interceptors: readonly RpcInterceptor[], + terminal: IncomingRpcNext, +): IncomingRpcNext { + let callNext = terminal; + for (let i = interceptors.length - 1; i >= 0; i--) { + const interceptor = interceptors[i]!; + const intercept = interceptor.interceptIncoming; + if (!intercept) continue; + const inner = callNext; + callNext = (invocation) => intercept.call(interceptor, invocation, inner); + } + return callNext; } /** diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index 8eb9abd2..e2badffc 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -490,6 +490,27 @@ describeE2E('livekit-rtc e2e', () => { calleeRoom!.localParticipant!.registerRpcMethod(method, async (data) => data.payload); + // interceptors see every call on both sides, with the method name attached to the + // invocation; the payload is what they see on the way in + const outgoing: string[] = []; + const incoming: string[] = []; + callerRoom!.localParticipant!.addRpcInterceptor({ + async interceptOutgoing(call, next) { + const response = await next(call); + outgoing.push(`${call.method}:${call.payload}->${response}`); + return response; + }, + }); + calleeRoom!.localParticipant!.addRpcInterceptor({ + async interceptIncoming(invocation, next) { + try { + return await next(invocation); + } finally { + incoming.push(`${invocation.method}:${invocation.callerIdentity}`); + } + }, + }); + // `room.connect()` resolves on the signal handshake, so the first // data-channel message still waits on ICE/DTLS/SCTP setup — seconds, on a // small runner. Warm the channel up untimed so the assertions below @@ -521,6 +542,17 @@ describeE2E('livekit-rtc e2e', () => { }), ).rejects.toMatchObject({ code: RpcError.ErrorCode.UNSUPPORTED_METHOD }); + expect(outgoing).toEqual([ + `${method}:${payload}->${payload}`, + `${method}:${payload}->${payload}`, + ]); + // the unregistered method reached the callee's chain too, with next throwing + expect(incoming).toEqual([ + `${method}:${callerRoom!.localParticipant!.identity}`, + `${method}:${callerRoom!.localParticipant!.identity}`, + `unregistered-method:${callerRoom!.localParticipant!.identity}`, + ]); + // Short by design: no ack ever arrives for an absent participant, so the // timeout expiring *is* the behavior under test. await expect( From 43cfa5de1e9b907237e497b0f55271be54586b60 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 14 Sep 2026 22:14:23 -0700 Subject: [PATCH 2/2] test(rtc): unknown methods are rejected before the interceptor chain The FFI layer answers UNSUPPORTED_METHOD for a method nobody registered before the SDK's handler runs, so the callee's incoming interceptor never sees such a call in a real room. The e2e assertion expected it to; the Python equivalent only held in unit tests with fake continuations. Co-Authored-By: Claude Fable 5.1 --- packages/livekit-rtc/src/rpc.ts | 5 +++-- packages/livekit-rtc/src/tests/e2e.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/livekit-rtc/src/rpc.ts b/packages/livekit-rtc/src/rpc.ts index 8dcabb5b..e0f5dedf 100644 --- a/packages/livekit-rtc/src/rpc.ts +++ b/packages/livekit-rtc/src/rpc.ts @@ -80,8 +80,9 @@ export type IncomingRpcNext = (invocation: RpcInvocationData) => Promise * Errors flow through the chain unchanged: an {@link RpcError} thrown by the remote side * (outgoing) or by the handler (incoming) is visible to every interceptor before it reaches the * caller. On the incoming side, any other error thrown by the handler is also visible; the SDK - * converts it to `APPLICATION_ERROR` only after the chain settles, and a call for an - * unregistered method reaches the chain with `next` throwing `UNSUPPORTED_METHOD`. + * converts it to `APPLICATION_ERROR` only after the chain settles. Calls for methods nobody + * registered are normally rejected by the transport before the SDK is involved; should one + * reach the chain anyway, `next` throws `UNSUPPORTED_METHOD`. * * @example * Time every RPC in both directions: diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index e2badffc..912feeef 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -546,11 +546,11 @@ describeE2E('livekit-rtc e2e', () => { `${method}:${payload}->${payload}`, `${method}:${payload}->${payload}`, ]); - // the unregistered method reached the callee's chain too, with next throwing + // the unregistered method never reached the callee's chain: the FFI layer rejects a + // method nobody registered before the SDK's handler is invoked expect(incoming).toEqual([ `${method}:${callerRoom!.localParticipant!.identity}`, `${method}:${callerRoom!.localParticipant!.identity}`, - `unregistered-method:${callerRoom!.localParticipant!.identity}`, ]); // Short by design: no ack ever arrives for an absent participant, so the