Compare commits

...
196 Commits
Author SHA1 Message Date
Johannes Marbach 8be229a438 Merge pull request #4354 from element-hq/toger5/johennes/rtc-remote-publish
Fall back to publishing on a remote SFU if possible
2026-10-15 16:27:36 +02:00
Timo K. ef7dce7b07 Update url-transport.test.ts 2026-10-15 16:10:02 +02:00
Timo K. 2de5ee2df5 Update test to consider EWs support for cs-api 2026-10-15 12:53:37 +02:00
Timo K. a427206792 Only return null if there is no endpoint available. 2026-10-15 12:42:36 +02:00
Timo K. 1913a099ab bump EW to inlcude the new widget capabilities 2026-10-15 11:28:45 +02:00
TimoandJohannes Marbach 93b893527e Update sdk/src/media-backend/livekit/RemotePublishTransport.ts
Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-10-15 09:28:40 +02:00
Timo K. 2b26fd472f fmt 2026-10-14 19:03:22 +02:00
Timo K. 281fff0e9f array -> prop|null in discoverLocalTransport 2026-10-14 19:01:14 +02:00
Johannes Marbach bc557dfb5d Revert "Temporarily enable full output to debug the Claude action"
This reverts commit 4827ab5fdc.
2026-10-14 17:55:35 +02:00
Johannes Marbach 4827ab5fdc Temporarily enable full output to debug the Claude action 2026-10-14 17:50:20 +02:00
Timo K. 7af6525f01 fix lints 2026-10-14 16:44:31 +02:00
Timo K. 3b763d87f2 make the transport discovery lk edgecase a EC exclusive workaround
Dont add it to the sdk.
2026-10-14 16:40:27 +02:00
Timo K.andClaude Fable 5.1 8638d4f159 Fall back to publishing on a remote SFU if possible
Port of edf1feae from johannes/rtc-remote-publish onto the SDK split,
where
the MatrixRTC logic lives in sdk/ rather than src/state/CallViewModel/.

Where the original lands:

- getLocalTransport's remote fallback: the LiveKit backend's new
  findRemotePublishTransport (sdk/src/media-backend/livekit/
  RemotePublishTransport.ts), which tries the candidates from the
  js-sdk's
  getRemoteLivekitPublishCandidates oldest first and keeps the first
  whose
  homeserver issues a token with the publish grant.
  discoverLocalTransport
  asks the backend for it after the host's fallback, with a snapshot of
  the
  session's memberships from RTCParticipation, and throws
  MatrixRTCTransportMissingError where that finds nothing too. Transport
  discovery stays backend neutral: the MSC4195 token check is the
  backend's.
- LocalMember's "no delegation on a remote SFU" and the custom
  transport's
  server_name: LivekitBackend.prepareLocalTransport locates the
  transport on
  the homeserver it names (serverNameOfTransport) and skips the
  delegation
  probe where that is not our own.
- SessionBehaviors' membership.getTransportServerName: the same helper
  in
  LivekitBackend.mediaFor.
- decodeSFUJWTPayload, SFUJWTPayload.video.canPublish and the exported
  isCsApiUnavailable: sdk/src/media-backend/livekit/auth/.
- DeveloperSettingsTab, the widget capabilities, the locale string and
  the
  js-sdk pin (9405287c, 44.0.0): applied as is. The error boundary
  snapshots follow the string.

Tests: the LocalTransport cases go to RemotePublishTransport.test.ts and
LocalTransport.test.ts, the LocalMember remote case and the
SessionBehaviors
server-name case to LivekitBackend.test.ts, plus the memberships
snapshot in
RTCParticipation.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 16:09:11 +02:00
Timo 4b73ce68e3 Merge pull request #4319 from element-hq/toger5/use-sdk
[MatrixRTCSdk] use the matrixRtcSdk
2026-10-14 10:59:05 +02:00
Timo K. d37599a794 Tag the video element with its track source for e2e specs
The SDK MediaView renders a plain video element, so LiveKit's
data-lk-source attribute is gone. Expose data-source from the SDK track
and point the screen-share spec at it.
2026-10-14 10:23:29 +02:00
Timo K. 3f98e36c56 test MatrixRTCError conversion 2026-10-14 10:23:29 +02:00
Timo K. 042a8cfbee Note the SDK coverage added on the package branch and the reconcile fix 2026-10-14 10:23:29 +02:00
Timo K. e726c36f67 Drop the auth modules main's PR 4331 added under src
The SDK replaced `src/livekit` and the remote-member modules before that PR
landed on `main`, so its `src/livekit/auth/*` and `remoteMembers/ConnectionMap.ts`
have no consumer here: the same code lives under `sdk/src/media-backend/livekit`.
The imports of them that `main` added to the view model, the developer
settings tab and the test utils go with them.
2026-10-14 10:23:29 +02:00
Timo K. dd25238427 Document how PR 4331 lands on the SDK split
Per-commit mapping of element-hq/element-call#4331 onto sdk/ and src/, the changes to api.ts, where the PR's tests went and what was checked.
2026-10-14 10:23:29 +02:00
Timo K. 432ebadd44 Key media tiles by the membership id
The bumped js-sdk reports the legacy identity as rtcBackendIdentity for every membership, so a device that rejoins after an improper leave, which has two memberships until the stale one expires, produced two tiles with the same key and crashed the call view. The membership id tells them apart; the audio renderer keys by it too.
2026-10-14 10:23:29 +02:00
RobinandJohannes Marbach 289260531b Let developers override the whole transport
The developer setting that overrode the LiveKit service URL becomes a
JSON transport, so that a `url`-only or combined transport can be tested
against a homeserver that does not advertise one yet. The settings modal
shows each LiveKit room's role instead of a never-populated isLocal flag.

Folds "Avoid null in transport dev setting" from the same PR. The setting feeds the SDK's transport option; the config fallback becomes a transport the host builds.

Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-10-14 10:23:29 +02:00
Robin f97dd2adab Show the whole transport in debug UI and logs
A transport may soon carry a LiveKit SFU `url` instead of, or as well as,
a `livekit_service_url`, so a service URL alone no longer identifies it.
Pass the whole transport to the tiles' debug stats and print it as JSON in
log lines instead of picking out the service URL.

Element Call's media view models carry the member's raw transport from the SDK's TransportMetadata to the tiles.
2026-10-14 10:23:29 +02:00
Robin 1700e9fe20 Match LiveKit participants against all backend identities
A member authenticated through the legacy JWT endpoint publishes under a
`user:device` identity, while one authenticated through the default
endpoint publishes under a hashed identity. Both are listed in the
membership's backend identities, so match against the whole list rather
than only the primary one.

Element Call's test membership mock, matching the SDK's RTCMember.
2026-10-14 10:23:29 +02:00
Timo K. f638604fe7 codecov as seperate coverage figures 2026-10-14 10:23:29 +02:00
Timo K. b0659279ee update to sdk api change: audioLevel stats$() 2026-10-14 10:23:29 +02:00
Timo K. 79c1f82448 use abstracted livekti processor 2026-10-14 10:23:29 +02:00
Timo K. eaf747c107 adjust to encrypted$ -> encrypted and VideoProcessor independent of LK 2026-10-14 10:23:29 +02:00
Timo K. aac60b74dc remove waitForKeyRotationMs as its not used anymore 2026-10-14 10:23:29 +02:00
Timo K. 598ec42390 use updated state logic in the sdk 2026-10-14 10:23:29 +02:00
Timo K. cec5031f62 sdk-target-based-on-call-view-model client -> slot/participation 2026-10-14 10:23:29 +02:00
Timo K. a5a0efe92b use sdk client -> participation/slot seperation 2026-10-14 10:23:29 +02:00
Timo K. 7c5d1324e7 Update to changes in the sdk.
- plain tracks instead of media$
2026-10-14 10:23:29 +02:00
Timo K. 52aaf84c3e update EC based on sdk media refactor
No more call specifics in the sdk layer
2026-10-14 10:23:29 +02:00
Timo K. 5f3db07b76 use the matrixRtcSdk 2026-10-14 10:23:29 +02:00
Timo 10b34b0218 Merge pull request #4316 from element-hq/toger5/matrixrtc-sdk-package
[MatrixRTCSdk] add the sdk
2026-10-14 10:23:24 +02:00
Timo K. d6b8207218 Merge branch 'main' into toger5/matrixrtc-sdk-package 2026-10-14 10:00:56 +02:00
renovate[bot] 956671eef5 Update ghcr.io/element-hq/element-web:develop Docker digest to e464b2a (#4351)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-14 02:33:22 +00:00
Timo K.andClaude Fable 5.1 28e1a9e22f Keep the CI sdk package name so main's preview deploy finds it
ploy previews run pr-deploy.yaml from main, which downloads
build-output-sdk,
so a renamed artifact can never be found from a pull request. Keep the
package
name and point build:sdk:production and build:sdk:development at the
CallViewModel bundle the preview is meant to carry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 19:04:02 +02:00
Timo K. 27dcfb8bb7 [SDK] Drop the landed plan documents and cut the migration document down
MediaBackendPlan.md and MemberMediaPlan.md described changes that have
landed. SdkMigration.md keeps only what moved from src/ into sdk/, with
the
paths as they are today. README.md gains the onboarding notes on the
three
API files, and its example uses createRTCSlot, which replaced
createMatrixRTCClient.
2026-10-13 17:10:04 +02:00
Timo K. 038991fa97 [SDK] Cover transport discovery, the join options and the room options
Unit tests for the pieces that had none: which transport the local
member advertises (override, homeserver, fallback, none), what the js-sdk
join is called with, how a publish request becomes LiveKit capture and
publish options, and the room options the connection factory builds from
the initial requests and the media quality settings, including what a
resolved connection reports while it is up. The backend's `mediaFor` gains
the cases from Element Call's member matching tests: a member on another
transport, a member changing transport, and a member without one.
2026-10-13 16:17:13 +02:00
Timo K. 2994bb4ed0 [SDK] Cover the publisher, the local media and the participation
The publisher is driven through a real LocalParticipant whose device and
signalling calls are faked, so that LiveKit's own pending-publication
bookkeeping is part of what is tested: the cases ported from Element
Call's Publisher tests (one permission prompt for both devices, nothing
created while muted, the upstream held back until the member publishes,
a mute that lands while the track is still being created) plus the
publish, unpublish and destroy paths.

The local media runs on a fake connection map: publishing before and
after the connection exists, republishing on a new connection, mutes
remembered across connections, the first publish error kept, and the
output device applied to the current room and the ones to come.

The participation runs on a fake media backend: the join options, the
state machine through connecting, connected, reconnecting, failed and
left, publishing only while joined, the delayed leave delegation, the
member ids per mode, the key replay and the data channel's sender check.
2026-10-13 16:13:47 +02:00
Timo K. d84dcc75de [SDK] Keep a value that reaches reconcile as its loop winds down
The loop that drives `scope.reconcile` was marked as running until the
promise of its last run had settled, which is a few microtasks after the
loop itself had checked for new values. A value arriving in that window
found the loop "running", was left to it, and was never processed: a
connection appearing at that moment would never get its publisher.

The flag is now cleared the moment the loop ends, so such a value starts
a new loop. The regression test drives the window microtask by microtask.
2026-10-13 16:13:47 +02:00
Timo K. 7c3f3d3ac7 Merge remote-tracking branch 'origin/main' into toger5/matrixrtc-sdk-package
# Conflicts:
#	sdk/README.md
2026-10-13 13:15:23 +02:00
Timo K. 7e4e260273 [SDK] Describe the transport auth flows in the architecture document
How a LiveKit transport is authenticated with by shape and role, the server name on connections and the options that now take a Transport.

(cherry picked from commit 26d9e1b0ae1e1f1ba8c3689bd50ba2e34aa0e820)
2026-10-13 13:00:58 +02:00
Robin 4aa249daf5 [SDK] Let developers override the whole transport
The developer setting that overrode the LiveKit service URL becomes a
JSON transport, so that a `url`-only or combined transport can be tested
against a homeserver that does not advertise one yet. The settings modal
shows each LiveKit room's role instead of a never-populated isLocal flag.

On the SDK the participation options take a whole Transport for the override and the fallback instead of two LiveKit service URLs, which also takes the last LiveKit knowledge out of transport discovery.

(cherry picked from commit d3eb40db0bcf7370418c4948049b3eac176bbdc1)
2026-10-13 13:00:58 +02:00
Robin b4bd1f81a0 [SDK] Move SFU authentication into livekit/auth and choose the endpoint by role
The OpenID flow moves to src/livekit/auth/openID.ts with the SFU config
types in a sibling file, takes an options object, and replaces the
MatrixRTC mode with the role we take on the transport plus the session's
slot ID instead of a hard-coded m.call#ROOM.

This changes behaviour: publishers on a livekit_service_url transport
always use the legacy /sfu/get endpoint, so that subscribers can predict
their user:device participant identity, and subscribers always use the
default /get_token endpoint. The MatrixRTC mode no longer influences which
endpoint is used.

On the SDK this lands as sdk/src/media-backend/livekit/auth/ with the connection map, the discriminated connection state and the client-server API flow of the following commits of element-hq/element-call#4331, which rewrite the same files:
- Model ConnectionState as a discriminated union
- Show the whole transport in debug UI and logs (SDK part)
- Let the publishing Connection authenticate itself
- Reduce ConnectionManager to a ConnectionMap
- Use the unstable LiveKit transport type
- Identify transports by server name as well as URLs
- Get SFU tokens and delegate delayed leaves through the MSC4195 CS API
- Fall back to get_token as subscriber on widget API errors
- Share a connection between homeservers using the same JWT service
- Make the auth choice mode independent again

Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
(cherry picked from commit adb380cd9ed108856e42d992fc03ced8c44f1e56)
2026-10-13 13:00:58 +02:00
Robin de8b5d98ec [SDK] Match LiveKit participants against all backend identities
A member authenticated through the legacy JWT endpoint publishes under a
`user:device` identity, while one authenticated through the default
endpoint publishes under a hashed identity. Both are listed in the
membership's backend identities, so match against the whole list rather
than only the primary one.

On the SDK this is RTCMember.backendIdentities, which the LiveKit backend matches media and data packets against; the harness keys its tiles by the membership id, which the backend identity no longer tells apart.

(cherry picked from commit c9aad2a2b3279782d101c9bc6c80af0417ccbb92)
2026-10-13 13:00:58 +02:00
Johannes Marbach 7685947a69 Switch JS SDK back to develop
The js-sdk at this commit also renamed MatrixRTCSession.leaveRoomSession to leave, which the SDK follows.

(cherry picked from commit 405348945febc1df10edf9c00748f1edd6b39e71)
2026-10-13 13:00:58 +02:00
Johannes Marbach b158c4fcd7 Merge pull request #4331 from element-hq/robin/livekit-updates
Support migration to new LiveKit transport types and endpoints
2026-10-13 07:38:02 +02:00
renovate[bot] dc8f8e585a Update ghcr.io/element-hq/element-web:develop Docker digest to 06ac258 (#4349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-13 01:36:04 +00:00
Johannes Marbach ee7dcdee7e Cover the subscriber token path, widget fallback and server names 2026-10-12 21:28:04 +02:00
Johannes Marbach 8d1ed31b0b Cover identity matching and delegation across transport shapes 2026-10-12 21:09:15 +02:00
Johannes Marbach 54e948fdc0 Make the auth choice mode independent again 2026-10-12 20:37:14 +02:00
Timo 441fe5accd Merge pull request #4348 from element-hq/toger5/room-v-12-create-room-fix
Room v12 compatibility: Dont send invalid `users` power level section.
2026-10-12 17:39:52 +02:00
Johannes Marbach 37d25d4293 Merge branch 'main' into robin/livekit-updates 2026-10-12 16:29:13 +02:00
Timo 079185b232 Merge pull request #4347 from element-hq/toger5/CI-playwright-improvements
[CI Playwirght] Make reporter more verbose + fail faster
2026-10-12 16:18:13 +02:00
Timo K. 558fbe2188 Dont send invalid users power level section. 2026-10-12 16:13:48 +02:00
Johannes Marbach 022415e98e Avoid null in transport dev setting 2026-10-12 16:11:03 +02:00
Johannes Marbach a1575cb554 Share a connection between homeservers using the same JWT service 2026-10-12 16:08:56 +02:00
Johannes Marbach 97b5e61ad8 Fall back to get_token as subscriber on widget API errors 2026-10-12 16:03:00 +02:00
Johannes Marbach f8258f6a1d Switch JS SDK back to develop 2026-10-12 15:43:44 +02:00
Johannes Marbach 68107d119e Retain compatiblity in compatiblity mode 2026-10-12 15:40:27 +02:00
Timo e96f578b63 Merge pull request #4346 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v11.2.0
2026-10-12 14:12:06 +02:00
renovate[bot] cfaae8f7e2 Update dependency @vector-im/compound-design-tokens to v11.2.0 2026-10-12 11:01:00 +00:00
Timo 1a90e813e8 Merge pull request #4338 from element-hq/toger5/compound-portal-root
[ElementCallComponent] Use compound-web's PortalRoot in EC.
2026-10-12 12:59:28 +02:00
Timo K.andClaude Fable 5.1 9d0a12c38a Fix PortalRoot hiding the component's menus and tooltips
compound-web 10.4.0 treats a PortalRoot whose root is null as one whose root
is not available yet, so floating-ui never mounts the portal and every tooltip
label, and with it every icon button's accessible name, is missing. The
component defaulted portalRoot to null and wrapped the call unconditionally.

Wrap the call only when the host supplied a root, keeping null as the
not-yet-available case Compound defines, and say so on the prop. Cover it in
CallPortalRoot.test.tsx. Drop the git allowBuilds entry for compound-web now
that it is a release again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 12:43:29 +02:00
Timo K. a1c98ac4e6 make reporter more verbose + let CI playwright fail faster 2026-10-12 12:38:10 +02:00
Timo K. b72493f47f bump compound-web to release 10.4.0 2026-10-12 11:15:03 +02:00
Johannes Marbach 49223e720b Cover url transports end to end
The specs replace the transports the e2e homeserver advertises with each
shape a migrating deployment may produce and record which token endpoints
each side used: a url-only or combined transport on both sides goes
through the CS API alone, a legacy guest joining a url host uses the
legacy endpoint to publish and the CS API to subscribe, a subscriber falls
back to the JWT service when the homeserver lacks MSC4195, a publisher
gets an error instead, a delayed leave delegated through the CS API fires
on disconnect, and subscribers get tokens for a remote homeserver's SFU
over federation.

The JWT service's app service registration has to cover users of the e2e
Synapse's real server name for the get_token flow, and lk-jwt-service
0.8.0 implements the endpoints.
2026-10-12 10:33:58 +02:00
Robin c5044119ce Deprecate the livekit.livekit_service_url config option
Homeservers should advertise their transports over the MSC4143
rtc/transports endpoint instead of relying on a fallback in the app
config.
2026-10-12 10:33:38 +02:00
Robin f366e7cbe2 Let developers override the whole transport
The developer setting that overrode the LiveKit service URL becomes a
JSON transport, so that a `url`-only or combined transport can be tested
against a homeserver that does not advertise one yet. The settings modal
shows each LiveKit room's role instead of a never-populated isLocal flag.
2026-10-12 10:33:19 +02:00
Robin e336ee5cfa Get SFU tokens and delegate delayed leaves through the MSC4195 CS API
src/livekit/auth now fronts two flows. For a transport with a LiveKit SFU
`url`, tokens come from our own homeserver's MSC4195 get_token endpoint
and delayed leaves are delegated through delegate_delayed_leave, both via
the js-sdk's unstable client methods. Transports with only a
livekit_service_url keep using the OpenID flow against the JWT service.

A transport advertising both shapes lets a subscriber fall back to the
OpenID flow when the homeserver answers get_token with a 404 (Synapse
reports M_UNRECOGNIZED for unknown routes rather than M_NOT_FOUND), so a
client can still join calls hosted on servers that have not deployed
MSC4195 yet. Publishers never fall back: a homeserver that advertises a
`url` but does not implement the endpoints is misconfigured and the
admin should hear about it, so they get NoMatrix2AuthorizationService.
Delegation uses the same flow as the token so that the participant
identity the service computes matches.

The local member only probes a transport's delegate_delayed_leave
endpoint when it has a service URL. The developer settings tab validates
a custom service URL through the same facade.
2026-10-12 10:33:00 +02:00
Robin 17c3a7cb5d Pin matrix-js-sdk to the member_id branch
Temporary, until matrix-org/matrix-js-sdk#5588 merges and this can go
back to a develop bump. The branch only changes the get_token request
body from a member object to a member_id, so this is kept separate from
the code that uses it and can be dropped without touching that code.
2026-10-12 10:32:13 +02:00
Robin 53a0e273d6 Identify transports by server name as well as URLs
A transport with only a LiveKit SFU `url` has to be authenticated through
our own homeserver, which needs to know the name of the server owning the
SFU. Carry that name alongside the transport as a TransportLocator,
derived from the member's user ID for remote transports and from the
client's domain for our own, and key the connection map on the SFU URL,
the service URL and the server name together so that `url`, service URL
and combined transport shapes are deduplicated consistently.
2026-10-12 10:31:36 +02:00
Robin 83e1396bdd Use the unstable LiveKit transport type
UnstableLivekitTransport covers every shape a homeserver may advertise
while MSC4195 evolves: a LiveKit SFU `url`, a `livekit_service_url`, or
both. Switching to it means a service URL is no longer guaranteed, so the
remaining code that relies on one checks for it explicitly: delegation
probing skips transports without one, the connection map keys transports
by whichever URL they carry, and the OpenID flow rejects transports
without a service URL until the client-server API flow lands.
2026-10-12 10:30:52 +02:00
Robin d5bbb43e3a Reduce ConnectionManager to a ConnectionMap
createConnectionMap$ now returns the behavior of connections and their
participants directly and takes an already resolved local transport
instead of an observable of it. The CallViewModel wraps it with mapScoped
so that a new map is built, in a fresh scope, whenever the local transport
changes, and swallows local transport errors there since the local member
already surfaces them in the UI.
2026-10-12 10:26:54 +02:00
Robin 24b6a632dd Let the publishing Connection authenticate itself
getLocalTransport no longer authenticates with the SFU up front and hands
the resulting config to the connection. Instead the ConnectionManager tags
the connection whose transport equals the local one as the publisher, and
every Connection fetches its own SFU config for its role.

The local member now only joins the MatrixRTC session once its connection
is authenticated, so that the LiveKit room exists before remote members
try to get a subscriber token for it. Subscribers authenticating with the
OpenID flow lack permission to create the room and would otherwise fail
without retrying, causing a cascade of leaves and rejoins at the start of
a call.
2026-10-12 10:22:33 +02:00
Robin 17c3223883 Move SFU authentication into livekit/auth and choose the endpoint by role
The OpenID flow moves to src/livekit/auth/openID.ts with the SFU config
types in a sibling file, takes an options object, and replaces the
MatrixRTC mode with the role we take on the transport plus the session's
slot ID instead of a hard-coded m.call#ROOM.

This changes behaviour: publishers on a livekit_service_url transport
always use the legacy /sfu/get endpoint, so that subscribers can predict
their user:device participant identity, and subscribers always use the
default /get_token endpoint. The MatrixRTC mode no longer influences which
endpoint is used.
2026-10-12 10:17:39 +02:00
Timo K. c79675e6a9 bump to compound-web main branch 2026-10-12 10:14:49 +02:00
Robin f260755f46 Group livekitRoomItems$ by connection
The old reducer keyed items by service URL and mutated the accumulator's
entries in place, so a transport without a service URL could not be
represented and items were not safe to compare. Group members by their
Connection object instead and carry the whole transport on the item, which
the audio renderer now receives in place of a URL.
2026-10-12 10:11:54 +02:00
Robin 3927c2b7dc Show the whole transport in debug UI and logs
A transport may soon carry a LiveKit SFU `url` instead of, or as well as,
a `livekit_service_url`, so a service URL alone no longer identifies it.
Pass the whole transport to the tiles' debug stats and print it as JSON in
log lines instead of picking out the service URL.
2026-10-12 10:09:50 +02:00
Robin 4e4ab23cda Model ConnectionState as a discriminated union
The enum mixed our own lifecycle states with a copy of livekit-client's
connection states, and errors were smuggled through the same observable as
a separate union member. A discriminated union keeps the LiveKit state
nested under "authenticated", carries the error as data, and lets the
local member represent the time before a connection exists as an
"initializing" state instead of null.
2026-10-12 10:08:22 +02:00
Robin 3f030e64dd Match LiveKit participants against all backend identities
A member authenticated through the legacy JWT endpoint publishes under a
`user:device` identity, while one authenticated through the default
endpoint publishes under a hashed identity. Both are listed in the
membership's backend identities, so match against the whole list rather
than only the primary one.
2026-10-12 10:04:47 +02:00
Johannes Marbach d8becaf868 Advertise the loopback address from the dev LiveKit SFUs
The container IP is not routable from the host when Docker runs in a VM
(colima, Docker Desktop), so calls against the dev stack drop once ICE
fails. Advertising 127.0.0.1 instead lets the browser reach the SFU through
the published ports. Firefox ignores loopback candidates, so this only
works in Chromium-based browsers.
2026-10-12 10:03:03 +02:00
Robin 994cd36c44 Fix typo in the name of the createJWTToken Playwright fixture 2026-10-12 10:03:03 +02:00
Johannes Marbach 7ac5373af9 Remove stray pause and improve logging for hung e2e tests
The list reporter is the only progress a hung CI run leaves behind, and a
global timeout under the job's 60 minute limit means a hung run still ends
with a report instead of being cancelled by GitHub.
2026-10-12 10:03:03 +02:00
renovate[bot] 32fcede65e Update ghcr.io/element-hq/element-web:develop Docker digest to d43e8d2 (#4345)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-12 02:40:40 +00:00
renovate[bot] 6438c18b73 Update ghcr.io/element-hq/element-web:develop Docker digest to 88eeb34 (#4344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 00:49:17 +00:00
renovate[bot] f07e9f5c70 Update ghcr.io/element-hq/element-web:develop Docker digest to 1488790 (#4343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-10 02:09:04 +00:00
Johannes Marbach c97e3bf7a6 Merge pull request #4337 from element-hq/johannes/remove-per-participant-e2ee-param
Remove perParticipantE2EE
2026-10-09 12:01:45 +02:00
Timo K. 674692c926 Fix breaking Ci tests due to compound-web 10.1.0 -> 10.3.1 bump 2026-10-09 11:09:41 +02:00
Timo K. c3d07b89bd review: unnecassary matchMedia overwrite on test start. 2026-10-09 11:08:03 +02:00
renovate[bot] f89cbc140e Update ghcr.io/element-hq/element-web:develop Docker digest to 181a526 (#4339)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-09 01:18:17 +00:00
Timo K. 3bd892ebd3 bump compound-web 2026-10-08 21:26:34 +02:00
Timo K. f322af2742 [ElementCallComponent] Use compound-web's PortalRoot in EC. 2026-10-08 19:14:01 +02:00
Johannes Marbach a9fa48f68c Remove perParticipantE2EE 2026-10-08 17:15:19 +02:00
Robin 471c76c0e7 Merge pull request #4336 from element-hq/robin/vite-warning
Fix Vite warning
2026-10-08 16:40:45 +02:00
Timo K. 799619557b [SDK] make stats$ a polled method and add audioLevel$ to audio tracks 2026-10-08 16:36:25 +02:00
Timo K. 5f914b8a01 [SDK] refactor track processor api to be lk independent 2026-10-08 16:36:17 +02:00
Timo K. aa897ae9bc [SDK] simplify public api (no lk processor, no encryption$ as obs) 2026-10-08 16:36:11 +02:00
Johannes Marbach 4881fd5fc6 Merge pull request #4280 from element-hq/johannes/claude
Add workflow for automated Claude review of pull requests
2026-10-08 15:27:13 +02:00
Johannes Marbach 49c29f160a Add workflow for automated Claude review of pull requests 2026-10-08 14:57:19 +02:00
Robin aa8f73e632 Fix Vite warning 2026-10-08 14:23:59 +02:00
Timo K. 2bb870f6e7 [SDK] fix slots missing initial status value 2026-10-08 13:53:57 +02:00
Timo K. e44dfe1898 [SDK] add codecov 2026-10-08 13:53:57 +02:00
Timo K. b5fac4a2ff Adapt to matrix-js-sdk develop and cover RTCSlot's js-sdk imports 2026-10-08 13:53:57 +02:00
Timo K. 66f326fcf0 [SDK] Update SdkArchitecture.md 2026-10-08 13:53:57 +02:00
Timo K. 3cdb452d5d [SDK] update state structure (less duplication, one state enum)
- remove media from LocalMembership file
2026-10-08 13:53:57 +02:00
Timo K. f49c1cd3d4 [SDK] rename file LocalMember -> LocalMembership 2026-10-08 13:53:57 +02:00
Timo K. dc983e5493 [SDK] split the clint object into -> slot and participation 2026-10-08 13:53:57 +02:00
Timo K. 9cc9eb0889 Create Publisher.test.ts 2026-10-08 13:53:57 +02:00
Timo K. ca4f0cdeae fixup docs 2026-10-08 13:53:57 +02:00
Timo K. 4790af08a4 flatten media api (no MemberMedia wrapper object 2026-10-08 13:53:57 +02:00
Timo K. 6ffab92f0f split api.ts into api.ts and media-api.ts 2026-10-08 13:53:57 +02:00
Timo K. f1ea4cc5a2 Merge LocalMediaInputs into MatrixRTCClientOptions
The SDK now takes `publish` requests and audio output control directly
in the main options object, removing the separate `LocalMediaInputs`
type. Audio output device selection moves from a reactive behavior to an
imperative method on `MatrixRTCClient`, giving hosts direct control over
when devices switch.
2026-10-08 13:53:57 +02:00
Timo K. 5b13e1da81 renames and restructure
- session -> matrixrtc (folder)
 - move MatrixRtcClient to top level of sdk
 - RtcTransport -> Transport
 - backend -> media-backend
2026-10-08 13:53:57 +02:00
Timo K. d0a2ff12f1 Put the media behind a backend seam and move LiveKit into it
The session speaks MatrixRTC and sees media through MediaBackend in
sdk/src/backend/api.ts. Everything LiveKit lives in
sdk/src/backend/livekit/,
the only place in the SDK that imports livekit-client; the import
boundary rule
now enforces that outside sdk/src/backend/ the LiveKit packages appear
in type
imports only.

The seam sits above the connection manager: one connection per transport
url
is the LiveKit multi-SFU strategy, not a MatrixRTC concept, so a
cascading SFU
or full mesh replaces the folder, not the session. A backend prepares
the local
transport the session discovered (the token exchange and the delegation
probe,
reported as canDelegateDelayedLeave), hands the delayed leave over per
delay
id, gives the local member one publish switch plus publish/unpublish,
builds a
remote member's media from its membership and registers that
membership's
transport for as long as the member lives, and reports its connections
for the
transport registry, which keys on the raw transport serialised with
sorted
keys. The backend's context carries no Matrix client (a backend picks
what it
calls in its own options), no membership list, and no js-sdk session:
media
keys arrive as a stream replayed on subscribe, and MatrixKeyProvider
subscribes
to it. The MatrixRTC mode no longer reaches LiveKit; it becomes a
tokenEndpoint
choice made where the default backend is built.

Two behaviour changes ride along. Publishing is one boolean, joined and
homeserver reachable, where before a sync hiccup after leave() resumed
the
upstream of tracks that were meant to stay silent. And
ResolvedConnection keeps
one ResolvedTransport object per fetch, so a state change no longer
reads as a
change of what was fetched.

MatrixRTCClientOptions gains backend?: MediaBackendFactory, defaulting
to
createLivekitBackend built from the client options.
authenticateWithTransport
takes tokenEndpoint instead of matrixRTCMode.
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 64d7eb99cc Add the media words to the architecture vocabulary
media, track, source, publish request, active and attach/detach, between
participant and transport, and the participant row names the adapter as
the only reader.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 cad00aef08 Mark the speaker detection check as fixme
Chromium's fake microphone never registers as a speaker at the SFU, and Firefox's steady tone does so in about two runs out of three, with or without the browser's audio processing. The test stays, out of the gate, until the dev SFU gets a lower speaker threshold or the harness a louder fake device.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 6bd6e3f953 Take the word call out of the member API
membership$ is typed as RTCMembership, the subset of the js-sdk CallMembership that hosts read. UnknownCallError is UnknownRTCError throughout, not only in the export. applicationData reaches the membership whole, since the js-sdk takes it next to the intent. The plan records the three slices as implemented.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 b9f79be106 Publish and unpublish on the local member
One way to put media on the session: publish(request) resolves with the local track once LiveKit has it, and unpublish(id) removes it. A request names a source with its device, processor and capture settings, so CaptureSettings on the client options goes, as do setMicrophoneEnabled and setCameraEnabled on the client and the four screen share fields on the local member; sharing is tracks$ having a screenShare track, and a capture failure rejects the promise. LocalMediaInputs becomes the list of initial requests, which also become the room defaults so that one permission prompt covers microphone and camera. The publisher keeps the requests across a reconnection and republishes what is not muted. The harness gains a Share screen button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 5a43eaf5a6 Give the local tracks their controls
Our own tracks are LocalAudioMediaTrack and LocalVideoMediaTrack, with setEnabled and setDevice, and on video facingMode$, switchFacingMode and setProcessor. switchCamera on the local media goes, as do the device and processor behaviors in LocalMediaInputs: a host calls the track when its setting changes instead of the SDK watching behaviors it does not own. The input device ids and the processor stay as initial values for the tracks created at the join. setMicrophoneEnabled and setCameraEnabled stay for the case of no track yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 63ccfa50be Test and document the MemberMedia track list
Unit tests for tracks$ and trackBySource$, an end-to-end check of isActive$ through a mute cycle (skipped on Chromium, whose fake microphone the SFU never hears), the architecture document brought in line with the track list, and the changed behaviour recorded in the plan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K. 18e0065086 specific call tracks -> generic tracks 2026-10-08 13:53:57 +02:00
Timo K. d7ab2b27b6 Create MemberMediaPlan.md 2026-10-08 13:53:57 +02:00
Timo K. 3afe52bd4a Create MediaBackendPlan.md 2026-10-08 13:53:57 +02:00
Timo K. d89c058275 Update SdkMigration.md 2026-10-08 13:53:57 +02:00
Timo K. 2af87a760d add support for sending data to the rtcSdk 2026-10-08 13:53:56 +02:00
Timo K. 5c6d2f0891 Update SDK to be consumed by Element Call's CallViewModel
Update the public API to export the primitives and operators the call
model needs, and change `LocalMediaInputs` from observable flags to
immediate booleans with imperative setters. Add session timings, media
quality and capture settings as configuration options, and allow the
host to override the transport discovery with explicit URLs.
2026-10-08 13:53:56 +02:00
Timo K. 31eafb394b 7000 loc COPIED from element call
They are copied for now but will be removed from element call once it
adopts the sdk. Those are no real line additions.
2026-10-08 13:53:56 +02:00
Timo K. 61e353d919 renames + rewriting docs 2026-10-08 13:53:56 +02:00
Timo K. 1a999eaa54 sdk implementation 2026-10-08 13:53:56 +02:00
Timo K.andClaude Fable 5.1 9910496b90 Add the MatrixRTC SDK package, its harness and a failing smoke test
Set up `sdk/` as a package shaped exactly like `component/`: its own
package.json with peer dependencies, the pnpm workspace marker so a host can
install it as a git dependency, a build tsconfig for type emission, and a vite
lib config with an externals list that `pnpm lint:externals` now checks for
both library builds.

The package holds only the interface for now. `createRtcSession` returns an
empty object; `RtcSession`, `RtcMember`, `MemberMedia` and `TransportMetadata`
are the contract Element Call's `CallViewModel` will be rebuilt on (see
sdk-plan.md). The shared primitives are re-exported from `src` until the first
implementation slice moves them.

`sdk/dev` is the smallest consumer of the SDK: a plain page that logs in, joins
a room and renders one tile per member, served by `pnpm dev:sdk` on port 3002.
Playwright starts it as a third web server, and `playwright/sdk/smoke.spec.ts`
has two browsers join one session. It fails on the status line with the SDK's
own error until there is an implementation behind the interface.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:56 +02:00
Timo K.andClaude Fable 5.1 2b9fdfe4d5 Move the SDK demo to sdk-target-based-on-call-view-model
The `sdk/` directory is about to become the MatrixRTC SDK package, so the
demo that lives there — one bundle of Element Call's CallViewModel for a plain
page or widget — moves aside, unchanged. It keeps building under a script and
vite config of its own, `pnpm build:sdk-target-based-on-call-view-model`, into
its own `dist` rather than the repository root's, and no longer copies the
developer's config.json out of `public`. Its README says which script to run
and where the output lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:56 +02:00
Johannes Marbach 14c561af8d Merge pull request #4300 from element-hq/johannes/pure-layout-functions
Make the layout decisions pure / testable functions
2026-10-08 13:20:44 +02:00
Johannes Marbach 4fd2f66949 Explain why we use merge 2026-10-08 11:14:26 +02:00
Johannes Marbach 53e038b09b layoutShallowEquals -> shallowEquals 2026-10-08 10:57:03 +02:00
renovate[bot] 3b7ccb63e2 Update ghcr.io/element-hq/element-web:develop Docker digest to 53a4fe6 (#4334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-08 02:17:28 +00:00
Johannes Marbach 1f07f949b5 Merge pull request #4330 from element-hq/renovate/npm-postcss-selector-parser-vulnerability
Update dependency postcss-selector-parser to v7.1.6 [SECURITY]
2026-10-07 14:59:28 +02:00
renovate[bot] a60036b3d7 Update dependency postcss-selector-parser to v7.1.6 [SECURITY] 2026-10-07 12:37:14 +00:00
Timo 2eb3a2fd2d Merge pull request #4329 from element-hq/toger5/remove-lk-client-from-external-deps
Remove livekit-client from the list of external dependencies
2026-10-07 14:34:47 +02:00
Timo K. 8b6adf067e remove unnecassary (confused) comment 2026-10-07 14:20:42 +02:00
Timo K. b408610277 Remove livekit-client from the list of external dependencies 2026-10-07 12:01:21 +02:00
renovate[bot] 2faa6c3dac Update ghcr.io/element-hq/element-web:develop Docker digest to 589c490 (#4328)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-07 01:42:16 +00:00
renovate[bot] 9b3c3569ec Update ghcr.io/element-hq/element-web:develop Docker digest to 39c94f0 (#4327)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-06 02:57:18 +00:00
Johannes Marbach a4654d9d00 Merge pull request #4315 from element-hq/johannes/workflow-call
Allow workflow_call on lint and test workflows
2026-10-05 14:07:07 +02:00
Johannes Marbach 6e5f71c796 Revert back to negative flags 2026-10-05 13:53:52 +02:00
Johannes Marbach 067f3d3038 Fix repo selection and flags 2026-10-05 13:44:48 +02:00
Johannes Marbach 6d70221fe6 Flip boolean flags 2026-10-05 13:04:34 +02:00
renovate[bot] 427376f78b Update ghcr.io/element-hq/element-web:develop Docker digest to 573daa2 (#4323)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 09:47:57 +00:00
Johannes Marbach ccfd36923d Merge pull request #4324 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v11.1.0
2026-10-05 08:41:57 +02:00
Johannes Marbach 89567726f8 Fix tests 2026-10-05 08:13:58 +02:00
Robin 9cd111b5f2 Merge pull request #4318 from element-hq/redact-posthog-user-id
Redact analytics ID from rageshakes
2026-10-05 07:44:16 +02:00
renovate[bot] 096aaac9f3 Update dependency @vector-im/compound-design-tokens to v11.1.0 2026-10-05 01:27:09 +00:00
renovate[bot] 8835a6d75d Update ghcr.io/element-hq/element-web:develop Docker digest to 3cf23ff (#4322)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-04 09:34:33 +00:00
renovate[bot] bfb868aec3 Update ghcr.io/element-hq/element-web:develop Docker digest to 5371503 (#4321)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-03 01:48:09 +00:00
Robin 4e8fb6e4cb Redact analytics ID from rageshakes
To improve our minimization of external data we collect in rageshakes.
2026-10-02 16:51:00 +02:00
Robin 7c53a6be5a Merge pull request #4317 from element-hq/remove-wait-for-rotation
Remove wait_for_key_rotation_ms config option
2026-10-02 15:14:14 +02:00
Robin 64b932f128 Remove unused vite-plugin-generate-file dependency 2026-10-02 14:57:34 +02:00
Robin bfad85a3af Remove wait_for_key_rotation_ms config option
I learned that makeKeyDelay has been deprecated in matrix-js-sdk ever since the switch to the new encryption manager, so this config option has no effect.
2026-10-02 14:49:44 +02:00
Johannes Marbach be822adec8 Allow workflow_call on lint and test workflows 2026-10-02 13:33:29 +02:00
Johannes Marbach 4c0f02ef47 Merge pull request #4309 from element-hq/johannes/layered
Set up a layered build for CI similar to what EW does
2026-10-02 13:22:19 +02:00
Johannes Marbach ca6319f06d Remove layered boolean 2026-10-02 13:07:09 +02:00
Timo 1d57a8eee1 Merge pull request #4308 from element-hq/toger5/fix-component-sounds
[ElementCall Component] Decode inlined sounds directly instead of fetching them
2026-10-02 12:45:31 +02:00
Timo K. 0bb9632b60 review play sounds does not throw 2026-10-02 11:46:54 +02:00
Timo K. cbe195d449 review audio data loading 2026-10-02 11:46:40 +02:00
Robin e800fec74d Merge pull request #4314 from element-hq/renovate/element-web
Update ghcr.io/element-hq/element-web:develop Docker digest to 588f0b4
2026-10-02 11:29:05 +02:00
Robin 9d0725ad31 Fix Element Web integration tests 2026-10-02 10:51:31 +02:00
renovate[bot] 241412f29b Update ghcr.io/element-hq/element-web:develop Docker digest to 588f0b4 2026-10-02 02:54:17 +00:00
Timo K. d96d00b64a Merge branch 'main' into toger5/fix-component-sounds 2026-10-01 19:07:39 +02:00
Robin cde88a7c93 Merge pull request #4312 from element-hq/js-sdk-develop
Reset matrix-js-sdk to develop branch, adopt new join/leave signatures
2026-10-01 18:55:16 +02:00
Robin d5733e8274 Reset matrix-js-sdk to develop branch, adopt new join/leave signatures 2026-10-01 17:11:24 +02:00
Timo K. 3befe3d34b Merge branch 'main' into toger5/fix-component-sounds 2026-10-01 13:58:23 +02:00
Timo 3ad778e7e8 Merge pull request #4306 from element-hq/toger5/fix-component-tooltips
[ElementCall Component] Make compound a peer dependency with EW. (fix tooltips in embedded mode)
2026-10-01 13:57:58 +02:00
Timo K. 9a286032f2 shorten readme 2026-10-01 13:41:07 +02:00
Timo K. 363a8feab2 Stop bundling Compound, let the host supply it 2026-09-30 17:10:17 +02:00
Johannes Marbach bd8c3142a6 Set up a layered build for CI similar to what EW does 2026-09-30 11:35:06 +02:00
Timo K. 16c60c8b6b Warn when the audio context fails to set up. 2026-09-30 11:22:19 +02:00
Timo K. 63ac1ba03e Decode inlined sounds directly instead of fetching them, with the test. 2026-09-30 11:22:15 +02:00
Robin 9e190e9e57 Merge pull request #4307 from element-hq/update-js-sdk
Adapt to matrix-js-sdk removal of livekit_alias and oldest_membership
2026-09-30 10:41:37 +02:00
Robin f287fb3800 Adapt to matrix-js-sdk removal of oldest_membership support 2026-09-30 00:45:19 +02:00
Robin f9b601504b Stop sending livekit_alias in memberships
It's been many versions since anything used the value.
2026-09-30 00:45:19 +02:00
Timo K. 9140365251 Merge branch 'main' into toger5/fix-component-tooltips 2026-09-29 16:25:15 +02:00
Timo 81233e5cfb Merge pull request #4269 from element-hq/toger5/component-api-entry
Export EC component related types
2026-09-29 16:24:26 +02:00
Timo K.andClaude Fable 5.1 e296d88969 Keep the component's tooltips inside its root
Compound's Tooltip floats its bubble through floating-ui's FloatingPortal,
into the body: outside the element the component build confines its
stylesheet to, so a label tooltip shows as bare text at all times. An
enclosing FloatingPortal rooted at the container makes the nested portals
land inside it instead.

The call goes in an isolating wrapper there, standing in for the standalone
page's #root, so that its footer layer no longer paints above the modals.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:08:44 +02:00
Robin fbce73ae55 Merge pull request #4302 from element-hq/always-multi-sfu
Remove multi-SFU check which is always true
2026-09-29 09:14:42 +02:00
renovate[bot] ce79274ccc Update ghcr.io/element-hq/element-web:develop Docker digest to cbb7be9 (#4303)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-29 01:07:59 +00:00
Robin 8db2e8ac41 Remove multi-SFU check which is always true
Compatibility and Matrix 2.0 are the only MatrixRTC modes now.
2026-09-28 22:03:16 +02:00
Johannes Marbach 6600c891f3 Extract the layout decision into a pure function 2026-09-28 17:36:24 +02:00
Johannes Marbach 33cfd2d8ad Build the layout media from one snapshot of its inputs 2026-09-28 17:34:22 +02:00
Johannes Marbach 5a8714805c Extract the spotlight and PiP choice into a pure function 2026-09-28 17:29:41 +02:00
Johannes Marbach 232e3f7ad4 Extract the spotlight speaker choice into a pure function 2026-09-28 17:28:14 +02:00
Johannes Marbach 10adf6658b Let layoutShallowEquals compare any layout-shaped object 2026-09-28 17:27:04 +02:00
Johannes Marbach 29742006df Move WindowMode next to the layout types to prevent cyclic import 2026-09-28 17:26:11 +02:00
Johannes Marbach 5c497cc6a8 Fix build on Windows 2026-09-25 16:15:39 +02:00
Timo K. 8ebb0b5a90 Export component related types. 2026-09-19 08:46:30 +02:00
269 changed files with 18943 additions and 11101 deletions
+3
View File
@@ -102,6 +102,9 @@
- name: "backport-candidate"
description: "Something that is a candidate for backport to a particular release branch"
color: "0B8D85"
- name: "Claude-Review"
description: "Request code review from Claude"
color: "e57157"
- name: "customer-retainer"
color: "F44A5F"
- name: "dependencies"
+4 -1
View File
@@ -44,7 +44,9 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
run: "scripts/layered.sh --ignore-pnpmfile"
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
- name: Build Element Call
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
env:
@@ -62,5 +64,6 @@ jobs:
with:
name: build-output-${{ inputs.package }}
path: dist
if-no-files-found: error
# We'll only use this in a triggered job, then we're done with it
retention-days: 1
+4 -1
View File
@@ -73,7 +73,10 @@ jobs:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build_sdk_element_call:
# Use the embedded package vite build
# The 'sdk' package is the CallViewModel bundle that predates the sdk/
# package (build:sdk:production, sdk-target-based-on-call-view-model/), not
# the package itself. The name is shared with pr-deploy.yaml, which always
# runs from main, so it stays until the bundle's consumers have moved over.
uses: ./.github/workflows/build-element-call.yaml
with:
package: sdk
+90
View File
@@ -0,0 +1,90 @@
name: Claude
on:
pull_request:
types: [labeled]
permissions:
# The action exchanges the GitHub OIDC token for a short-lived Anthropic
# access token. GitHub API access goes through CLAUDE_GH_TOKEN instead.
id-token: write
jobs:
review:
name: Review
# Only run on labelling and skip forks.
if: github.event.label.name == 'Claude-Review' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# The review looks at the individual commits, not just the combined diff
fetch-depth: 0
persist-credentials: false
- name: Review
uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245
with:
anthropic_federation_rule_id: fdrl_01XQmWo4nka1ZTqZdsdeScyr
anthropic_organization_id: dfbc9a23-d636-4653-8970-035204ef60fa
anthropic_service_account_id: svac_018Ay5qaA7ZW8X7XsbdekqUM
anthropic_workspace_id: wrkspc_01Aw8LHjBS6fZysu9yqzYifW
# The token used here must grant read access to code, issues, and metadata as well as
# read and write access to pull requests.
github_token: ${{ secrets.CLAUDE_GH_TOKEN }}
use_sticky_comment: true
track_progress: true
prompt: |
Review this pull request under the following (and no further) angles:
1. Scope:
- Does the PR do one thing?
- Is the PR free of unrelated fixes and drive-by refactorings?
- If the PR is large, could it reasonably be split into smaller PRs
that still deliver value?
2. Commits:
- Is the work split into self-contained commits of reasonable size?
- Are the commits arranged in a sensible order?
- Have required refactorings been split out into separate commits?
- Is the history free of "wip", checkpoint and fixup-style commits
which only correct an earlier commit in the same PR?
3. Code:
- Are the code changes functionally correct?
- Do the changes introduce new security vulnerabilities or metadata
leaks?
- Is the new code performant enough?
- Don't check anything that is already validated via other GitHub
workflows like linting, formatting or tests passing.
Read the commits themselves with `git log` and `git diff` against
the base branch of the PR.
Drop any findings that are outside the above review angles or that don't
require resolution. Grade the severity of each finding as either "🟡 low",
"🟠 medium" or "🔴 high". Devise at least one possible resolution for each
finding.
Report the graded findings and resolutions from the "Code" angle as inline
comments. Add a footer "_-- Finding from automated review_" to each inline
comment.
Finally, report your overall result as PR comment. Start your message with
"# Automated review". Then add one section per review angle with:
- A verdict of "✅ PASS" or "❌ NEEDS WORK"
- A bullet list summarizing the remaining non-inlined graded findings and
resolutions (as nested bullets).
Be brief and concise in all your communication. Do not mention what you have
not covered and don't bring up issues that have already been fixed.
Use Markdown links for anything that can be navigated to on GitHub such
as files, commits, issues, pull requests, workflow runs, etc.
claude_args: |
--model claude-fable-5-1
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git log:*),Bash(git diff:*),Bash(git show:*)"
--max-turns 50
+12 -1
View File
@@ -1,6 +1,12 @@
name: Lint, format & type check
on:
pull_request: {}
workflow_call:
inputs:
matrix-js-sdk-sha:
type: string
required: false
description: The matrix-js-sdk commit to build against instead of the locked one
jobs:
lint:
name: Lint, format & type check
@@ -9,6 +15,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -19,7 +27,10 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
run: "scripts/layered.sh --ignore-pnpmfile"
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Formatting
run: "pnpm run format:check"
- name: i18n
+36 -3
View File
@@ -3,6 +3,25 @@ on:
pull_request: {}
push:
branches: [main]
workflow_call:
inputs:
matrix-js-sdk-sha:
type: string
required: false
description: The matrix-js-sdk commit to build against instead of the locked one
disable_coverage:
type: boolean
required: false
default: false
description: Skip generating and uploading coverage reports
disable_e2e_tests:
type: boolean
required: false
default: false
description: Skip the end-to-end tests
secrets:
CODECOV_TOKEN:
required: false
jobs:
vitest:
name: Run unit tests
@@ -11,6 +30,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -21,7 +42,10 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
run: "scripts/layered.sh --ignore-pnpmfile"
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
@@ -35,8 +59,11 @@ jobs:
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm playwright install --with-deps
- name: Vitest
run: "pnpm run test:coverage"
run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE"
env:
ENABLE_COVERAGE: ${{ !inputs.disable_coverage }}
- name: Upload to codecov
if: ${{ !inputs.disable_coverage }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@@ -45,11 +72,14 @@ jobs:
fail_ci_if_error: true
playwright:
name: Run end-to-end tests
if: ${{ !inputs.disable_e2e_tests }}
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -59,7 +89,10 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: pnpm install --frozen-lockfile --ignore-pnpmfile
run: scripts/layered.sh --ignore-pnpmfile
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
+3
View File
@@ -41,3 +41,6 @@ yarn-error.log
*storybook.log
storybook-static
# Nested checkout made by scripts/layered.sh
/matrix-js-sdk/
+1
View File
@@ -35,6 +35,7 @@
],
"element-call/no-observablescope-leak": "error",
"element-call/no-top-level-logger-get-child": "error",
"element-call/sdk-import-boundary": "error",
"jsdoc/empty-tags": "error",
"jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn",
+232
View File
@@ -0,0 +1,232 @@
# Merging PR #4331 onto the SDK / Element Call split
How [element-hq/element-call#4331](https://github.com/element-hq/element-call/pull/4331)
("LiveKit updates": MSC4195 client-server API tokens, `url` transports, delegation
through the homeserver, a transport dev override) lands on `toger5/use-sdk`, where
the MatrixRTC logic lives in `sdk/` and Element Call consumes it through
`sdk/src/api.ts`. The PR was written against `main`, where all of this still sat
in `src/state/CallViewModel/`. This document goes through the PR commit by commit,
says whether each one lands in `sdk/` or `src/`, and how; the section
[Changes to `api.ts`](#changes-to-apits) covers the public interface in full. The
end state: this branch behaves exactly as the PR does, with the split and the
`api.ts` seam intact.
The PR's branch is checked out at `agent-workspace/pr4331-merge/worktree` for
comparison. The `matrix-js-sdk` dependency is pinned to the same `develop`
commit the PR uses (`4df2f2bf`, 43.0.0), which brings `UnstableLivekitTransport`,
`CallMembership.backendIdentities`, `_unstable_getLivekitToken` and
`_unstable_delegateDelayedLeave`. That bump also renamed
`MatrixRTCSession.leaveRoomSession` to `leave`, which the SDK now calls.
## Where each commit lands
| # | Commit | Lands in | How |
| --- | ------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Remove stray pause and improve logging for hung e2e tests | e2e | Applied as is: `playwright.config.ts`, `playwright/widget/federated-call.test.ts`. |
| 2 | Fix typo in the name of the createJWTToken Playwright fixture | e2e | Applied as is. |
| 3 | Advertise the loopback address from the dev LiveKit SFUs | backend | Applied as is: `backend/dev_livekit*.yaml`. |
| 4 | Match LiveKit participants against all backend identities | sdk | `RTCMember.backendIdentities` (new, see api.ts). `LivekitBackend.mediaFor` matches `member.backendIdentities.includes(p.identity)`; `RTCParticipation.data$` maps a packet's sender the same way. `mockRtcMembership` in both `sdk/src/utils/test.ts` and `src/utils/test.ts` takes the new 4-argument constructor and a `backendIdentities` override. |
| 5 | Model ConnectionState as a discriminated union | sdk | `Connection.ts` state is `{state: "initialized" \| "authenticating" \| "authenticated" (+ `livekitState`) \| "error" (+ `error`) \| "stopped"}`. Internal to the LiveKit backend: `connectionState.ts` maps it onto the backend-neutral `MediaConnectionState`, so nothing above the backend changes. `DataChannel.ts` and `ConnectionFactory.ts` follow. |
| 6 | Show the whole transport in debug UI and logs | sdk + src | SDK log lines use `JSON.stringify(transport)`. Element Call: `focusUrl$: Behavior<string>` on the member media view models becomes `transport$: Behavior<Transport>` (`TransportMetadata.raw`), carried through `GridTile`, `SpotlightTile`, `MediaView` to `RTCConnectionStats`, which prints the JSON. The old code showed `extractDomain(transport.id)`, which on this branch was already a JSON string, so this also fixes a latent bug. |
| 7 | Group livekitRoomItems$ by connection | n/a | `livekitRoomItems$` and `LivekitRoomAudioRenderer` do not exist on this branch: audio renders from `tracks$` (`MemberAudioRenderer`). Nothing to port. |
| 8 | Move SFU authentication into livekit/auth and choose the endpoint by role | sdk | `sdk/src/media-backend/livekit/auth/{index,openID,csApi,types}.ts` replace `openIDSFU.ts`. Publisher → legacy `/sfu/get`; subscriber → `/get_token`. `slotId` replaces the hard-coded `m.call#ROOM`: `MediaBackendContext.slotId`, taken from the js-sdk session in `RTCParticipation`. The `tokenEndpoint` option of `createLivekitBackend` and the derivation from `MatrixRTCMode` are gone. |
| 9 | Let the publishing Connection authenticate itself | sdk | Every `Connection` has a `role` and fetches its own token; `existingSFUConfig` is gone. `prepareLocalTransport` no longer fetches a token itself: it starts the publisher connection (through the connection map) and resolves once that connection reports `authenticated`, rejecting with its error otherwise. Since `LocalMembership` joins only after `preparedTransport$` resolves, the join waits for the publisher's token exactly as the PR's `authenticated$` gate does. |
| 10 | Reduce ConnectionManager to a ConnectionMap | sdk | `ConnectionMap.ts` replaces `ConnectionManager.ts`; `createConnectionMap$` takes a resolved local transport, and `LivekitBackend` wraps it in `mapScoped` over the local transport, as the PR's `CallViewModel` did. `LocalMedia.ts`, `DataChannel.ts` take `connectionMap$`. |
| 11 | Use the unstable LiveKit transport type | sdk + src | `UnstableLivekitTransport` / `isUnstableLivekitTransport` throughout the backend; `src/settings/settings.ts`, `DeveloperSettingsTab`, test helpers. Transport discovery in `sdk/src/matrixrtc/` is now backend neutral (filters by `type`, see commit 15). |
| 12 | Identify transports by server name as well as URLs | sdk | `TransportLocator {transport, serverName}` in `auth/types.ts`; `ConnectionMap.keyFor` keys on `(url, livekit_service_url, serverName)`. The server name is derived from the user id (`serverNameOf`) for remote members and from our own membership identity for the local transport, so neither `api.ts` nor the backend options need it. |
| 13 | Pin matrix-js-sdk to the member_id branch | superseded | By commit 19; the lockfile pins the PR's final `develop` commit. |
| 14 | Get SFU tokens and delegate delayed leaves through the MSC4195 CS API | sdk | `auth/csApi.ts` (`_unstable_getLivekitToken`, `_unstable_delegateDelayedLeave`), `auth/index.ts` facade with the fallback rules. `LivekitBackend.delegateDelayedLeave` goes through the facade; the delegation probe asks only the service the publisher flow uses. `LivekitBackendOptions.client` picks the two new client methods. |
| 15 | Let developers override the whole transport | sdk + src | `RTCParticipationOptions.transport` / `fallbackTransport` take a `Transport` instead of `transportUrl` / `fallbackTransportUrl` (see api.ts). Element Call: setting `custom-transport` (JSON) replaces `custom-livekit-url`; `DeveloperSettingsTab` edits JSON and validates through `authenticateWithTransport`; `InCallView` turns `config.livekit.livekit_service_url` into the fallback transport. Locale keys `developer_mode.custom_transport.*`. |
| 16 | Deprecate the livekit.livekit_service_url config option | src | Applied as is in `ConfigOptions.ts`. |
| 17 | Cover url transports end to end | e2e + backend | `playwright/url-transport.spec.ts`, `backend/app-service*.yaml`, `docker-compose-dev.yml` (lk-jwt-service 0.8.0) copied as is. |
| 18 | Retain compatibility in compatibility mode | superseded | Reverted by commit 23 inside the PR; only its e2e spec edits survive, in the final spec file. |
| 19 | Switch JS SDK back to develop | deps | `pnpm-lock.yaml` pins `4df2f2bf` and `matrix-widget-api` 1.20.0; `package.json` keeps `#develop`. |
| 20 | Fall back to get_token as subscriber on widget API errors | sdk | `isCsApiUnavailable` in `auth/index.ts`; `doNetworkOperationWithRetry(op, shouldRetry)` in `sdk/src/utils/network.ts` with `isNetworkError` in `csApi.ts`. |
| 21 | Share a connection between homeservers using the same JWT service | sdk | `keyFor` ignores the server name for transports without a `url`; `removeDuplicateTransports` keeps the first locator so the local one wins. |
| 22 | Avoid null in transport dev setting | src | `transportText()` in `DeveloperSettingsTab.tsx`. |
| 23 | Make the auth choice mode independent again | sdk | `chooseAuthFlow(transport, role)` has no mode parameter; `config.ts` documents that `MatrixRTCMode` decides how the membership is written, not how the transport is authenticated. |
| 24 | Cover identity matching and delegation across transport shapes | sdk + e2e | Unit tests in `LivekitBackend.test.ts` (see below); `playwright/url-transport.spec.ts` final version. |
| 25 | Cover the subscriber token path, widget fallback and server names | sdk + e2e | `auth/openID.test.ts` subscriber cases; `playwright/widget/url-transport.test.ts`; the server-name test lives in `LivekitBackend.test.ts` ("connects to a remote member's transport on the member's homeserver"). |
## Changes to `api.ts`
Three changes to the public SDK interface, all forced by the PR's behaviour.
### `RTCMember.backendIdentities` (new)
```ts
/** The membership's `rtcBackendIdentity`, `${userId}:${deviceId}`: a key and a debug label. */
rtcBackendIdentity: string;
/**
* Every identity the member may publish under on its transport: the
* participant identity the SFU sees, and the sender of its data packets.
* One per authentication flow the member's transport allows, so a transport
* with both a `url` and a `livekit_service_url` lists the hashed and the
* legacy identity, and the backend matches whichever shows up.
*/
backendIdentities: readonly string[];
```
Commit 4 of the PR. A member on a transport that carries both a `url` and a
`livekit_service_url` may have authenticated through either flow and so has
two candidate identities; the js-sdk now lists them on the membership. The
backend matches media and data packets against the list.
`rtcBackendIdentity` stays as the stable per-membership key Element Call uses
for tile ids and the debug overlay, and its doc says that it is no longer
what the backend matches on. Read off the membership, like the single identity
before it; the backend will derive both once the identity scheme is its own.
### `RTCParticipationOptions.transport` / `fallbackTransport` replace the url options
```ts
/**
* Use this transport instead of asking the homeserver. Any shape the
* backend serves: for LiveKit a `url`, a `livekit_service_url` or both.
*/
transport?: Transport;
/** Use this transport when the homeserver advertises none of the backend's type. */
fallbackTransport?: Transport;
```
Commit 15 of the PR. The developer override has to carry a whole transport so
that a `url`-only or combined shape can be tested against a homeserver that
does not advertise one yet; a URL cannot say which shape it is. This also
removes the one piece of LiveKit knowledge that sat outside the backend:
`LocalTransport.ts` and `TransportAutoDiscovery.ts` no longer build a
`{type: "livekit", livekit_service_url}` object and filter by `transportType`
only, which `LocalTransport.ts` had marked as the moment to do this. The
host turns its config URL into a transport (`InCallView.rtcParticipationOptions`).
### `TransportMetadata` is unchanged, with one documented limit
`TransportMetadata.id` is still the raw transport serialised with sorted keys.
The LiveKit backend now keys its connections on the transport plus the server
name where the transport has a `url` (commits 12 and 21), so two homeservers
advertising the same `url` get two connections but one `TransportMetadata`.
`connectedTransports$` deduplicates, and `resolved$` reports the first
connection's resolution. This is diagnostics only; the media and data paths
go through the backend's own keys. Extending `TransportMetadata` with the
server name was left out to keep the type transport neutral; a developer panel
that needs it can be given a `serverName` on `BackendConnection` later.
### Related, outside `api.ts`
- `media-backend/api.ts`: `MediaBackendContext.slotId` (commit 8). The
client-server API issues a token for a slot, so the backend needs it;
`RTCParticipation` reads it from the js-sdk session.
- `sdk/index.ts`: `authenticateWithTransport` is now the auth facade's
`getSFUConfig({client, membership, transport, serverName, roomId, slotId, role, logger})`
(object parameter, whole transport, server name); `AuthenticateWithTransportParams`
is exported for it; `TokenEndpoint` is gone.
- `createLivekitBackend` options: `tokenEndpoint` is gone; `client` now picks
`_unstable_getLivekitToken` and `_unstable_delegateDelayedLeave` as well.
- `config.ts`: the `MatrixRTCMode` doc no longer claims the mode picks the
JWT endpoint (commit 23).
## Behaviour, side by side
| Behaviour | PR on `main` | This branch |
| -------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Endpoint choice | `chooseAuthFlow` in `src/livekit/auth` | Same function, `sdk/src/media-backend/livekit/auth/index.ts` |
| Join waits for the publisher's token | `authenticated$` gate in `LocalMember.ts` | `prepareLocalTransport` resolves on the publisher connection's `authenticated` state; `LocalMembership` joins after it |
| Delegation probe | The flow's service only, in `LocalMember.joinParams$` | The flow's service only, in `prepareLocalTransport` |
| Delegation handover | `delegateDelayedLeave` facade from `LocalMember` | Same facade from `LivekitBackend.delegateDelayedLeave`, with the delay timeout from the session timings instead of `Config` |
| Transport keys and connection sharing | `ConnectionMap.keyFor` on `TransportLocator` | Same, moved |
| Server names | `membershipsAndTransports$` from the user id; `getLocalTransport` from `client.getDomain()` | `serverNameOf(userId)` for members; our own membership identity's user id for the local transport |
| Identity matching | `membership.backendIdentities.includes(p.identity)` | `member.backendIdentities.includes(p.identity)` in `mediaFor` and for data packets |
| Dev transport override | `customTransport` setting → `getLocalTransport` | `customTransport` setting → `RTCParticipationOptions.transport` |
| Config fallback | `RtcTransportAutoDiscovery.tryConfigTransport` | Host builds the transport, `RTCParticipationOptions.fallbackTransport` |
| Error for a `url` host without MSC4195 | `NoMatrix2AuthorizationService` from the publisher connection | Same error, surfaced through `prepareLocalTransport` → `state$ failed` → `fromMatrixRTCError` |
One deliberate difference: on `main` the publisher connection fetches its token
on every (re)creation and nothing else does; here it is the same, since the
up-front token fetch in `prepareLocalTransport` is gone. There is no second
token request per join.
## Where the PR's new tests went
| PR test | Here |
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/livekit/auth/index.test.ts` | `sdk/src/media-backend/livekit/auth/index.test.ts`. Rewritten on the network layer (fetch-mock for the JWT service, mocked client methods) because a `vi.mock` of the sibling `openID` module does not reach the facade's own import under `sdk/`. Same cases, plus a table for `chooseAuthFlow`. |
| `src/livekit/auth/openID.test.ts` | `sdk/src/media-backend/livekit/auth/openID.test.ts`, replacing `openIDSFU.test.ts` |
| `ConnectionMap.test.ts` (incl. commit 21's sharing tests) | `sdk/src/media-backend/livekit/ConnectionMap.test.ts`, replacing `ConnectionManager.test.ts`; plus a role-tagging test |
| `Connection.test.ts` changes | `sdk/src/media-backend/livekit/Connection.test.ts`; plus a publisher-through-the-JWT-service case |
| `LocalMember.test.ts` "joins session with %s delegation support" (by transport shape) | `LivekitBackend.test.ts` "prepares the local transport and asks %s about delegation", "waits for the publisher connection to hold its token before the join", "fails the preparation with the publisher connection's error", "delegates the leave through the flow that authenticated the publisher" |
| `MatrixLivekitMembers.test.ts` two-candidate-identity tests | `LivekitBackend.test.ts` › `mediaFor` |
| `SessionBehaviors.test.ts` server-name test | `LivekitBackend.test.ts` "connects to a remote member's transport on the member's homeserver" |
| `LocalTransport.test.ts` / `RtcTransportAutoDiscovery.test.ts` changes | `sdk/src/matrixrtc/TransportAutoDiscovery.test.ts` (fallback is a transport) |
| `DeveloperSettingsTab.test.tsx` custom transport cases | `src/settings/DeveloperSettingsTab.test.tsx`, snapshot updated for the label |
| `DataChannel`, `LivekitBackend` registry tests | Adapted to the state union and `TransportLocator` |
| `playwright/url-transport.spec.ts`, `playwright/widget/url-transport.test.ts` | Copied as is: they drive the app, which behaves the same |
The PR's `CallViewModel.test.ts`, `CallViewModelTestUtils.ts`, `LocalMember.test.ts`
(the rest) and `MatrixAudioRenderer.test.tsx` edits have no counterpart: those
modules were replaced by the SDK before this merge.
## One fix the e2e run forced
The bumped js-sdk sets `rtcBackendIdentity` to the legacy `${userId}:${deviceId}`
identity for every membership (it is deprecated there; the hashed one now only
appears in `backendIdentities`). On this branch the call view model keyed its
tiles by `[dup, mediaId, rtcBackendIdentity]`, so a device that rejoined after an
improper leave, which has two memberships until the stale one expires, produced
duplicate keys and crashed the host's page with `UNKNOWN_ERROR`
(`spa-call-sticky.spec.ts`, "rejoin after improper leave"). The key is now the
membership's own `memberId`, and `MemberAudioRenderer` and the SDK harness key
by it too. The PR on `main` keyed by the participant object as well and did not
hit this.
## Coverage added on the package branch, and one bug it found
The PR's tests mostly covered the auth layer; the SDK modules under them had
little or none. Added on the package branch, which this branch is rebased onto:
| Module | Test |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `media-backend/livekit/Publisher.ts` | `Publisher.test.ts`: the cases from `main`'s `localMember/Publisher.test.ts` on a real `LocalParticipant` with faked device and signalling calls, plus publish, unpublish and destroy |
| `media-backend/livekit/LocalMedia.ts` | `LocalMedia.test.ts`: on a fake connection map; publish before and after the connection, republish on a new connection, mutes remembered, first publish error, output device |
| `RTCParticipation.ts` | `RTCParticipation.test.ts`: on a fake media backend; join options, state machine, publishing only while joined, delegation, member ids per mode, key replay, data sender check |
| `matrixrtc/LocalTransport.ts` | `LocalTransport.test.ts`: override, homeserver, fallback, none |
| `matrixrtc/joinJsSdkSession.ts` | `joinJsSdkSession.test.ts`: the js-sdk join options, the retry budget, the intent and sticky events |
| `media-backend/livekit/publishOptions.ts` | `publishOptions.test.ts` |
| `media-backend/livekit/ConnectionFactory.ts` | `ConnectionFactory.test.ts`: the room options from the requests and media quality (the cases from `main`'s `ECConnectionFactory.test.ts`), `resolved$` |
| `LivekitBackend.ts` › `mediaFor` | A member on another transport, a member changing transport, a member without one (from `main`'s `MatrixLivekitMembers.test.ts`) |
`ConnectionFactory.test.ts` reads the options off the real LiveKit room the
factory builds instead of mocking `livekit-client`: a `vi.mock` of the package
reached the factory's import on the package branch but not on this one.
The `LocalMedia` tests found a race in `ObservableScope.reconcile`: the loop was
marked as running until the promise of its last run had settled, a few
microtasks after it had last checked for new values, so a value arriving in
that window (a connection appearing at just that moment) was never processed.
Fixed in `[SDK] Keep a value that reaches reconcile as its loop winds down`,
with a test that drives the window microtask by microtask. Element Call's
`src/state/ObservableScope.ts` re-exports the SDK's class, so the fix applies
to the app as well.
## Checked
- `pnpm lint` (tsc, oxlint, knip, externals), `pnpm format:check`, `pnpm i18n:check`.
- `pnpm test`: 119 unit files, 950 tests; 4 storybook files, 45 tests (after the coverage above).
- Playwright on Chromium against the local Playwright backend, one worker:
- `playwright/url-transport.spec.ts`: 10 of 10 pass, including delegation
through the homeserver and the JWT service, the subscriber fallback, the
publisher error and the federated token.
- `playwright/widget/url-transport.test.ts`: passes.
- The rest of `playwright/*.spec.ts`, `playwright/component`, `playwright/sdk`:
48 pass, 1 skipped, 1 fails (`reconnect.spec.ts`, which fails on `main`
locally too because the local JWT service is slower than the spec's wait).
- `playwright/widget`: 7 of 10 pass, including `federated-call`. `huddle-call`
fails before any call code runs (the Element Web develop image's room list no
longer matches the helper's "Open room" selector), and `screen-share` looks
for `video[data-lk-source="screen_share"]`, an attribute the LiveKit React
components set and the SDK's plain `<video>` elements do not: a failure of this
branch that predates the PR.
- Local environment work needed for that: the othersite JWT service and nginx
containers had to be recreated from the compose file, two stale containers held
port 16080, and the widget helper's hard-coded `https://localhost:3000` was
pointed at 3100 for the run only, since port 3000 is taken on this machine.
- Standalone, widget and component all go through `InCallView.rtcParticipationOptions`
and the SDK, so the change is the same in all three modes; the widget-specific
fallback (a host without MSC4533) is in `isCsApiUnavailable`.
+30 -10
View File
@@ -34,16 +34,16 @@ You can find the latest development version continuously deployed to
## ✨ Key Features
✅ **Decentralized & Federated** – No central authority; works across Matrix
homeservers.
✅ **End-to-End Encrypted** – Secure and private calls.
homeservers.
✅ **End-to-End Encrypted** – Secure and private calls.
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
in Matrix clients as a widget, or (experimentally) mount it as a React component
inside your own application.
✅ **WebRTC-based** – No additional software required.
inside your own application.
✅ **WebRTC-based** – No additional software required.
✅ **Scalable with LiveKit** – Supports large meetings via SFU
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
✅ **Raise Hand** – Participants can signal when they want to speak, helping to
organize the flow of the meeting.
organize the flow of the meeting.
✅ **Emoji Reactions** – Users can react with emojis 👍️ 🎉 👏 🤘, adding
engagement and interactivity to the conversation.
@@ -297,8 +297,8 @@ The package is not published yet. A host installs it as a git dependency on the
```
whose `prepare` script runs the build on install. That build needs pnpm (via
Corepack) on the host's machine, runs a full `pnpm install` of this repository
and is memory-hungry, since it inherits the `--max-old-space-size` setting of
Corepack) on the host's machine, runs a full `pnpm install` of this repository,
since it inherits the `--max-old-space-size` setting of
the app build; the host's pnpm also has to allow it to run at all
(`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm
project of its own for this reason, so pnpm commands run from inside that
@@ -306,8 +306,28 @@ directory target it rather than the repository; run them from the repository
root. The host imports the component from
`@element-hq/element-call-component` and the stylesheet from
`@element-hq/element-call-component/style.css`, and has to provide `react`,
`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves
them external.
`react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and
`@vector-im/compound-design-tokens` itself, since the bundle leaves them
external.
Compound's stylesheet are the host's to load too: the component shares
the host's copy of Compound, (required for example for tooltips)
The component is large, and a host will usually load it lazily, only once a
call is shown. Everything a host needs in order to talk about a call before
then is also exported
from `@element-hq/element-call-component/api` (a small entry point that does not
load the component).
- props and the types they are made of
- the handle
- the host bridge
- `UserIntent`
- `HeaderStyle`
- `BackgroundStyle` enums
- `configurationForIntent`, the defaults each intent implies
Import from it wherever a value such as
`BackgroundStyle.Solid` is needed on a path that must stay light; the main
entry point re-exports all of it too.
### Backend
+1 -1
View File
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
regex: "@.*:homeserver-1" # Cover all local users
regex: "@.*:synapse\\.othersite\\.m\\.localhost" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server-1:16080"
# Enable membership look-ups via /is_joined.
+1 -1
View File
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
regex: "@.*:homeserver" # Cover all local users
regex: "@.*:synapse\\.m\\.localhost" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server:6080"
# Enable membership look-ups via /is_joined.
+5
View File
@@ -6,6 +6,11 @@ rtc:
port_range_start: 50300
port_range_end: 50400
use_external_ip: false
# Advertise the loopback address instead of the container IP, which is not
# routable from the host when Docker runs in a VM (colima, Docker Desktop).
# The browser then reaches the SFU through the published ports. Firefox
# ignores loopback candidates, so this only works in Chromium-based browsers.
node_ip: 127.0.0.1
turn:
enabled: false
domain: localhost
+5
View File
@@ -6,6 +6,11 @@ rtc:
port_range_start: 50100
port_range_end: 50200
use_external_ip: false
# Advertise the loopback address instead of the container IP, which is not
# routable from the host when Docker runs in a VM (colima, Docker Desktop).
# The browser then reaches the SFU through the published ports. Firefox
# ignores loopback candidates, so this only works in Chromium-based browsers.
node_ip: 127.0.0.1
turn:
enabled: false
domain: localhost
+20
View File
@@ -0,0 +1,20 @@
# Coverage comes from a single vitest run (see .github/workflows/test.yaml), so
# the app and the SDK are split by path into codecov components rather than
# uploaded under separate flags. Each component gets its own coverage figure in
# the PR comment and on codecov.io.
#
# Validate with: curl --data-binary @codecov.yml https://codecov.io/validate
comment:
layout: "reach, diff, flags, components, files"
component_management:
individual_components:
- component_id: app
name: app
paths:
- src/**
- component_id: sdk
name: sdk
paths:
- sdk/**
+57
View File
@@ -0,0 +1,57 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { type ReactNode } from "react";
import { Tooltip, TooltipProvider } from "@vector-im/compound-web";
import { CallPortalRoot } from "./CallPortalRoot";
// A button whose accessible name comes from its tooltip, as every icon button
// in the call does: the name is lost whenever the tooltip has nowhere to go.
const LabelledButton = (): ReactNode => (
<TooltipProvider>
<Tooltip label="Microphone">
<button>🎤</button>
</Tooltip>
</TooltipProvider>
);
test("floats into the host document with no root", () => {
render(
<CallPortalRoot root={undefined}>
<LabelledButton />
</CallPortalRoot>,
);
expect(screen.getByRole("button", { name: "Microphone" })).toBeTruthy();
});
test("floats into the root it is given", () => {
const root = document.createElement("div");
document.body.appendChild(root);
render(
<CallPortalRoot root={root}>
<LabelledButton />
</CallPortalRoot>,
);
const button = screen.getByRole("button", { name: "Microphone" });
const label = document.getElementById(
button.getAttribute("aria-labelledby")!,
);
expect(root.contains(label)).toBe(true);
root.remove();
});
test("waits for a root that is not available yet", () => {
render(
<CallPortalRoot root={null}>
<LabelledButton />
</CallPortalRoot>,
);
expect(screen.queryByRole("button", { name: "Microphone" })).toBeNull();
});
+26
View File
@@ -0,0 +1,26 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, type ReactNode } from "react";
import { PortalRoot } from "@vector-im/compound-web";
interface Props {
root: HTMLElement | null | undefined;
children: ReactNode;
}
/**
* Where Compound floats the call's menus and tooltips. With no `root` the call
* is not wrapped at all and they go into the host document's body; `null` is
* Compound's "not available yet", and the floating parts wait for it.
*/
export const CallPortalRoot: FC<Props> = ({ root, children }): ReactNode =>
root === undefined ? (
children
) : (
<PortalRoot root={root}>{children}</PortalRoot>
);
+13 -5
View File
@@ -34,10 +34,18 @@ component rather than a page makes. */
-webkit-tap-highlight-color: transparent;
}
/* Compound's overlay container, which holds tooltips and popovers, has to fill
the container for the elements inside it to be positioned against it. The
standalone page does the same for the container under `#root`. */
.root > [data-overlay-container] {
position: relative;
/* The call itself. It is a stacking context of its own, as the standalone
page's `#root` is, so that the layers the call view uses for its header, footer
and overlays stay below the modals and toasts, which are portalled into the
root and carry no layer of their own.
What Compound floats — tooltips, menus — is not in here at all: it goes into
the host's body, as it would for any other component on the host's page, and is
styled and layered by the host's copy of Compound. */
.content {
display: flex;
flex-direction: column;
block-size: 100%;
position: relative;
isolation: isolate;
}
+138
View File
@@ -0,0 +1,138 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* EXPERIMENTAL
*
* What a host needs in order to talk about Element Call, without Element Call:
* the component's props and the types they are made of, the handle and the
* host bridge, the intents a call can be started with and the configuration
* each implies.
*
* This is an entry point of its own, `@element-hq/element-call-component/api`,
* so that a host can import it on a path that must stay light — a call model
* that needs `BackgroundStyle.Solid` as a value, say — and load the component
* itself, and everything it brings, only when a call is shown. The main entry
* point re-exports all of it, so a host that does not care may import
* everything from there.
*
* Nothing in here may reach anything that is not a type or a constant: the
* point of this module is what it does not import.
*/
import type { Ref } from "react";
import type { MatrixClient } from "matrix-js-sdk";
import type { UrlProperties } from "../src/UrlParams";
import type { ElementCallHandle, ElementCallHostBridge } from "./host";
import {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
} from "../src/UrlConfiguration";
// How the host and Element Call talk to each other, and what they say
export type { ElementCallHandle, ElementCallHostBridge } from "./host";
export type { DeviceMuteRequest, DeviceMuteState } from "../src/HostBridge";
export type { JoinCallData } from "../src/widget";
// The deployment-wide configuration, as distinct from ElementCallConfiguration
// below, which is per call
export type { ConfigOptions } from "../src/config/ConfigOptions";
// The values that appear in ElementCallConfiguration and in the intent, and
// what each intent means by default
export {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
};
/**
* How Element Call should behave. Everything is optional; anything left out
* takes the default that {@link ElementCallProps.intent} implies.
*
* This is the behaviour a widget can be configured with through its URL, plus
* the one fact about the call a host has a say in here, the background. The
* rest of what a widget's URL carries — who the user is, how to reach the
* homeserver, where to report analytics, the shared secret of a room that is
* encrypted with one — a component host supplies by other routes, or not at
* all; and what can change while the call is running, the theme and the
* language, is a prop of its own.
*/
export type ElementCallConfiguration = Partial<UrlConfiguration> &
Partial<Pick<UrlProperties, "background">>;
export interface ElementCallProps {
/**
* The client to place the call with. Element Call does not authenticate
* anyone or manage a session of its own; this one is the host's.
*/
client: MatrixClient;
/** The room to call in. The host's client must already know about it. */
roomId: string;
/**
* What the user asked for — whether they started the call or joined one that
* was already running, and whether it is a call in a group or a DM. Element
* Call decides what each of those means: whether to show the lobby first,
* whether to ring, and so on.
*
* Defaults to joining an existing group call, which is the most conservative
* reading, but a host that knows which button the user pressed should say so.
*/
intent?: UserIntent;
/**
* How Element Call should behave, overriding whatever {@link intent} implies.
* A host that finds itself setting a lot of these probably wants a different
* intent instead.
*
* Compared by value, so it is fine to write this inline; only a change to
* what it says restarts anything.
*/
config?: ElementCallConfiguration;
/**
* What Element Call tells the host while the call is running: that the user
* has joined or hung up, that it would like to be kept on screen, and so on.
* Without one, Element Call assumes nobody is listening.
*/
hostBridge?: ElementCallHostBridge;
/**
* What the host tells Element Call: to hang up, to mute, to join. Available
* once the component has rendered.
*/
ref?: Ref<ElementCallHandle>;
/**
* The theme to show Element Call in, `light` or `dark`. Left out, Element
* Call picks. Changes take effect at once, and cost nothing else.
*/
theme?: string;
/**
* The language to show Element Call in, as a BCP 47 tag: one of the
* `supportedLanguages` the main entry point exports, or something that falls
* back to one (`de-AT` to `de`). Left out, the browser's language is used.
*
* Translations are one thing shared by every Element Call on the page, so
* the most recently set language wins for all of them.
*/
language?: string;
/**
* Where the parts of Element Call that float above it — its menus and
* tooltips, which Compound portals out of the call — are rendered. Left out,
* they go into the host document's body, as they do for any other component
* on the host's page.
*
* A host that moves Element Call's DOM into another document, such as a
* Document Picture-in-Picture window, passes that document's body here so
* that a menu opens in the window its button is in. Changes take effect at
* once. `null` means the root is not available yet, as from a callback ref
* that has not fired: menus and tooltips wait for it rather than falling
* back to the body.
*/
portalRoot?: HTMLElement | null;
}
+8 -7
View File
@@ -40,10 +40,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
* Element Call as a component.
*
* As a page of its own, Element Call can style the document: normalize.css and
* Compound speak of `html`, `body` and bare elements, and the design tokens are
* declared on `:root`. As a component, all of that would land on the host's
* document too. This rewrites every selector so that it matches only the root
* or its descendants:
* its own base styles speak of `html`, `body` and bare elements, and its custom
* properties are declared on `:root`. As a component, all of that would land on
* the host's document too. This rewrites every selector so that it matches only
* the root or its descendants:
*
* - `html`, `body` and `:root` become the root element, which is what stands in
* for the document inside a host.
@@ -55,9 +55,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
* CSS modules are scoped by their class names already, so only their selectors
* that would match by element alone — `pre` rather than `.pre` — are touched.
*
* The root's fonts and design tokens are still inherited by everything inside
* it, the way they were from `body` and `:root`, and `@font-face` declarations
* stay global, which they are by nature.
* The root's custom properties are still inherited by everything inside it,
* the way they were from `:root`. Compound's own stylesheet — the design
* tokens, the component styles, the fonts — is not in this build at all: the
* host supplies it, unscoped, along with its copy of Compound.
*/
export function scopeStylesToRoot(): Plugin {
return {
+10
View File
@@ -9,6 +9,16 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { logger } from "matrix-js-sdk/lib/logger";
// What a host provides along with its copy of Compound: its stylesheet, the
// design tokens and the fonts they name. The component does not bundle them.
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css";
import "@fontsource/inconsolata/400.css";
import "@fontsource/inconsolata/700.css";
import "@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css";
import "@vector-im/compound-web/dist/style.css";
import { type ConfigOptions, initializeElementCall } from "../index";
import { Harness } from "./Harness";
// After Element Call's, so that the host has the last word on its own page
+52 -128
View File
@@ -17,33 +17,37 @@ Please see LICENSE in the repository root for full details.
* host instead, or is confined to the container it is mounted in.
*/
// The design tokens, fonts and element defaults every Element Call stylesheet
// The element defaults and custom properties every Element Call stylesheet
// builds on. Written for a page, they speak of `html`, `body` and bare
// elements; the component build confines them, and every other stylesheet in
// this bundle, to the root element below (see build/scopeStylesToRoot.ts), so
// that the host's document is left as it was.
//
// Compound is not in here. The component uses the host's copy of it (a peer
// dependency, left external by the build), and the host loads Compound's
// stylesheet, design tokens and fonts along with it: what Compound floats into
// the host's body — tooltips, menus — is then styled by the same stylesheet as
// everything else there, and Element Call's root takes the tokens and fonts of
// the theme class it carries, as any element on the host's page would.
//
// Where these land relative to the component stylesheets is the bundler's
// choice — the standalone app puts them first, this build puts them in the
// middle — so nothing in base.css may depend on winning or losing against a
// component's own rules at equal specificity. It currently does not: what it
// declares unlayered is custom properties on Element Call's root, which
// components inherit rather than compete with, and everything from Compound
// sits in a `@layer`, which loses to unlayered rules either way.
// components inherit rather than compete with.
import "../src/base.css";
import {
type FC,
type JSX,
type ReactNode,
type Ref,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { I18nextProvider } from "react-i18next";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -62,119 +66,32 @@ import { RootElementProvider, useRootElement } from "../src/RootElementContext";
import {
configurationForIntent,
componentProperties,
type UrlConfiguration,
type UrlParams,
UrlParamsProvider,
type UrlProperties,
UserIntent,
useUrlParams,
} from "../src/UrlParams";
import { MediaDevicesContext } from "../src/MediaDevicesContext";
import { MediaDevices } from "../src/state/MediaDevices";
import { ObservableScope } from "../src/state/ObservableScope";
import { ProcessorProvider } from "../src/livekit/TrackProcessorContext";
import { ProcessorProvider } from "../src/tracks/TrackProcessorContext";
import { Config } from "../src/config/Config";
import { type ConfigOptions } from "../src/config/ConfigOptions";
import { i18n } from "../src/utils/i18n";
import { useTheme } from "../src/useTheme";
import { useStableValue } from "../src/useStableValue";
import styles from "./ElementCall.module.css";
import {
type ElementCallHandle,
type ElementCallHostBridge,
useComponentHostBridge,
} from "./host";
import { useComponentHostBridge } from "./host";
import { CallPortalRoot } from "./CallPortalRoot";
import { type ElementCallProps } from "./api";
import { supportedLanguages, translationsBackend } from "./localization";
// The languages Element Call can be shown in
export { supportedLanguages } from "./localization";
// How the host and Element Call talk to each other, and what they say
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
export {
type DeviceMuteRequest,
type DeviceMuteState,
} from "../src/HostBridge";
export { type JoinCallData } from "../src/widget";
// The deployment-wide configuration, as distinct from ElementCallConfiguration
// above, which is per call
export { type ConfigOptions } from "../src/config/ConfigOptions";
// The values that appear in ElementCallConfiguration and in the intent
export {
BackgroundStyle,
HeaderStyle,
UserIntent,
type UrlConfiguration,
} from "../src/UrlParams";
/**
* How Element Call should behave. Everything is optional; anything left out
* takes the default that {@link ElementCallProps.intent} implies.
*
* This is the behaviour a widget can be configured with through its URL, plus
* the one fact about the call a host has a say in here, the background. The
* rest of what a widget's URL carries — who the user is, how to reach the
* homeserver, where to report analytics, the shared secret of a room that is
* encrypted with one — a component host supplies by other routes, or not at
* all; and what can change while the call is running, the theme and the
* language, is a prop of its own.
*/
export type ElementCallConfiguration = Partial<UrlConfiguration> &
Partial<Pick<UrlProperties, "background">>;
export interface ElementCallProps {
/**
* The client to place the call with. Element Call does not authenticate
* anyone or manage a session of its own; this one is the host's.
*/
client: MatrixClient;
/** The room to call in. The host's client must already know about it. */
roomId: string;
/**
* What the user asked for — whether they started the call or joined one that
* was already running, and whether it is a call in a group or a DM. Element
* Call decides what each of those means: whether to show the lobby first,
* whether to ring, and so on.
*
* Defaults to joining an existing group call, which is the most conservative
* reading, but a host that knows which button the user pressed should say so.
*/
intent?: UserIntent;
/**
* How Element Call should behave, overriding whatever {@link intent} implies.
* A host that finds itself setting a lot of these probably wants a different
* intent instead.
*
* Compared by value, so it is fine to write this inline; only a change to
* what it says restarts anything.
*/
config?: ElementCallConfiguration;
/**
* What Element Call tells the host while the call is running: that the user
* has joined or hung up, that it would like to be kept on screen, and so on.
* Without one, Element Call assumes nobody is listening.
*/
hostBridge?: ElementCallHostBridge;
/**
* What the host tells Element Call: to hang up, to mute, to join. Available
* once the component has rendered.
*/
ref?: Ref<ElementCallHandle>;
/**
* The theme to show Element Call in, `light` or `dark`. Left out, Element
* Call picks. Changes take effect at once, and cost nothing else.
*/
theme?: string;
/**
* The language to show Element Call in, as a BCP 47 tag: one of
* {@link supportedLanguages}, or something that falls back to one (`de-AT`
* to `de`). Left out, the browser's language is used.
*
* Translations are one thing shared by every Element Call on the page, so
* the most recently set language wins for all of them.
*/
language?: string;
}
// Everything a host needs to talk about Element Call — the props, the handle,
// the host bridge, the intents and what each means — also available without
// the component itself from `@element-hq/element-call-component/api`
export * from "./api";
/**
* Prepares the things Element Call needs before it can be shown: translations,
@@ -238,6 +155,7 @@ export const ElementCall: FC<ElementCallProps> = ({
ref,
theme,
language,
portalRoot,
}): ReactNode => {
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
@@ -321,34 +239,40 @@ export const ElementCall: FC<ElementCallProps> = ({
<UrlParamsProvider value={params}>
<div ref={setContainer} className={styles.root}>
{ready && (
<RootElementProvider value={container}>
{/* Whatever goes wrong in here is shown in here. Left to
propagate, an error would unmount the host's own tree. */}
<ErrorBoundary
fallback={(error) => <ErrorPage error={error} />}
// A broken call should not hold the host on screen
onError={() => void hostBridge.setAlwaysOnScreen(false)}
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
</RootElementProvider>
// Stands in for the standalone page's `#root`: see `.content`
// in ElementCall.module.css
<div className={styles.content}>
<CallPortalRoot root={portalRoot}>
<RootElementProvider value={container}>
{/* Whatever goes wrong in here is shown in here. Left to
propagate, an error would unmount the host's own tree. */}
<ErrorBoundary
fallback={(error) => <ErrorPage error={error} />}
// A broken call should not hold the host on screen
onError={() => void hostBridge.setAlwaysOnScreen(false)}
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
</RootElementProvider>
</CallPortalRoot>
</div>
)}
</div>
</UrlParamsProvider>
+6 -1
View File
@@ -25,6 +25,10 @@
"types": "./dist/types/component/index.d.ts",
"default": "./dist/element-call.js"
},
"./api": {
"types": "./dist/types/component/api.d.ts",
"default": "./dist/api.js"
},
"./style.css": "./dist/element-call.css"
},
"sideEffects": [
@@ -34,7 +38,8 @@
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component"
},
"peerDependencies": {
"livekit-client": "^2.18.1",
"@vector-im/compound-design-tokens": "^11.0.0",
"@vector-im/compound-web": "^10.3.1",
"matrix-js-sdk": "*",
"react": "^19",
"react-dom": "^19"
+3 -3
View File
@@ -13,8 +13,8 @@
"rootDir": "..",
"outDir": "./dist/types"
},
// The entry point, plus the ambient declarations (CSS modules, `?react` SVGs,
// `import.meta.env`, …) that the sources it reaches rely on.
"include": ["./index.tsx", "../src/@types/*.d.ts"],
// The entry points, plus the ambient declarations (CSS modules, `?react` SVGs,
// `import.meta.env`, …) that the sources they reach rely on.
"include": ["./index.tsx", "./api.ts", "../src/@types/*.d.ts"],
"exclude": []
}
-1
View File
@@ -7,7 +7,6 @@
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"network_error_retry_ms": 100
}
-1
View File
@@ -11,7 +11,6 @@
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_mode": "compatibility",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"network_error_retry_ms": 100
}
-1
View File
@@ -7,7 +7,6 @@
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"network_error_retry_ms": 100
},
-1
View File
@@ -7,7 +7,6 @@
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"network_error_retry_ms": 100
}
+2 -2
View File
@@ -3,7 +3,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
pull_policy: always
hostname: auth-server
environment:
@@ -33,7 +33,7 @@ services:
- ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
pull_policy: always
hostname: auth-server-1
environment:
+2 -2
View File
@@ -19,7 +19,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:cfbe7ceb346918257ef07cb25f7b9b03ec0575ecb5ce51dfeeef873e67c60c95
image: ghcr.io/element-hq/element-web:develop@sha256:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:cfbe7ceb346918257ef07cb25f7b9b03ec0575ecb5ce51dfeeef873e67c60c95
image: ghcr.io/element-hq/element-web:develop@sha256:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
+18 -2
View File
@@ -72,8 +72,24 @@ Code that builds in only one is a bug.
- `build:full` — standalone app, also widget mode.
- `build:embedded` — `@element-hq/element-call-embedded`.
- `build:sdk` — SDK library, entry `sdk/main.ts`.
- `build:sdk` — `@element-hq/matrixrtc-sdk`, sources in `sdk/`, entry `sdk/index.ts`.
Packaged exactly like the component (own pnpm project, run pnpm from the repo
root, types from `sdk/tsconfig.build.json`, externals checked by
`pnpm lint:externals`). Interface only so far; the design is `sdk-plan.md`.
- `build:sdk-target-based-on-call-view-model` — the SDK demo that predates the
package, one bundle of the `CallViewModel`; entry
`sdk-target-based-on-call-view-model/main.ts`. CI's `sdk` package
(`build:sdk:production` / `build:sdk:development`, the Netlify `-sdk` preview)
is still this bundle, not `build:sdk`.
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
`matrix-js-sdk` / `livekit-client` subpath the externals list omits.
`matrix-js-sdk` / `@vector-im/compound-web` /
`@vector-im/compound-design-tokens` subpath the externals list omits.
`livekit-client` is bundled on purpose: no host imports it, and the pinned
version travels with the component.
Compound's stylesheets are loaded by `src/index.css` for the page and by the
host for the component, never by `src/base.css`. Two entry
points: `index.tsx` (the component) and `api.ts` (types, enums and
`configurationForIntent`, for a host that must not load the component). `api.ts`
may only reach types and constants — `src/UrlConfiguration.ts`, not `UrlParams`.
+4
View File
@@ -39,6 +39,9 @@ translations. The browser is Playwright's, so a fresh clone needs
- `playwright/component/` — the component in a host page, via the harness on port
3001 that Playwright starts as a second web server. Catches container-relative
layout, styles escaping the root, two instances on a page, host-bridge reports.
- `playwright/sdk/` — the MatrixRTC SDK through its harness on port 3002, a page
with none of Element Call on it. The smoke test is what the implementation is
built against.
- `playwright/mobile/` — Pixel 7, `mobile` project only.
Test what a user observes: the peer sees the change, it survives a reconnect, it is
@@ -50,4 +53,5 @@ pnpm test # unit + storybook
pnpm backend # Synapse + LiveKit, required for e2e
pnpm test:playwright # or :open
pnpm dev:component # component harness, port 3001
pnpm dev:sdk # SDK harness, port 3002
```
+15
View File
@@ -60,6 +60,21 @@ git config --local core.hooksPath .githooks
This will add the hook path for this repository only to .gihooks. which is a tracked (by git) folder containing the pre-commit hook.
## Testing against a matrix-js-sdk branch in CI
Linking covers local development. In CI, `scripts/layered.sh` plays the same
role for `matrix-js-sdk`: after the usual frozen install, `scripts/fetchdep.sh`
looks for a matrix-js-sdk branch with the same name as the pull request's head
branch (in the fork first, when the pull request comes from one). If there is
one, it is cloned, packed and installed over the locked version. If there is
none, the install stays as `pnpm-lock.yaml` pins it.
This means a change that needs a not yet merged matrix-js-sdk change can be tested
by giving both branches the same name. This mirrors how element-web tests against
matrix-js-sdk. Once the matrix-js-sdk branch is merged and deleted, the pull
request falls back to the lockfile and needs a lockfile bump to stay green,
which is also what keeps `main` consistent after the merge.
## Background
Information, why this approach is used can be found in the [linking concept reasoning](./linking_concept_reasoning.md) document.
+1 -1
View File
@@ -24,7 +24,7 @@ homeserver does not support sticky events. This is the default.
MSC4354 and all clients on v0.17.0 or later. The local membership requests its
token from the Matrix 2.0 JWT endpoint of the
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
and identifies the room by a hashed identity instead of a `livekit_alias`.
and identifies the room by a hashed identity instead of the Matrix `room_id`.
(Remote memberships always try the new endpoint first and fall back to the
legacy one, so remote participants can be on either.)
-1
View File
@@ -61,7 +61,6 @@ These parameters are relevant to both [widget](./embedded_standalone.md) and [st
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
+128
View File
@@ -0,0 +1,128 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { dirname, resolve, sep } from "node:path";
import { ESLintUtils } from "@typescript-eslint/utils";
/**
* Packages a host supplies and the SDK must not know about: UI, i18n and
* Element Call's design system.
*/
const BANNED_PACKAGES = [
"react",
"react-dom",
"react-i18next",
"i18next",
"@vector-im/",
];
/**
* Packages only a media backend may import. The session speaks MatrixRTC and
* sees media through `sdk/src/media-backend/api.ts`; a backend under
* `sdk/src/media-backend/<name>/` is the one place its protocol's library appears.
*/
const BACKEND_PACKAGES = ["livekit-client", "@livekit/"];
/**
* Keeps the SDK free of Element Call: nothing under `sdk/` may import a file
* outside `sdk/` by relative path (that is `src/`, `component/`,
* `playwright/`) or one of the packages above. The development harness under
* `sdk/dev/` is a host, not part of the library, and is exempt. Keeps the
* session free of any one media backend, too: outside `sdk/src/media-backend/`,
* a backend's packages may only be imported for their types, and only in
* the public API, the errors and the test helpers that have to name them.
*/
const rule = ESLintUtils.RuleCreator(
() => "https://github.com/element-hq/element-call",
)({
name: "sdk-import-boundary",
meta: {
type: "problem",
docs: {
description:
"Disallow the SDK importing Element Call (anything outside sdk/) or host-supplied packages.",
},
messages: {
outsideSdk:
"The SDK must not import '{{specifier}}': it resolves outside sdk/. Copy or move what it needs into sdk/src.",
bannedPackage:
"The SDK must not import '{{specifier}}': a host supplies that, the SDK has no UI.",
backendPackage:
"Only a media backend under sdk/src/media-backend/ may import '{{specifier}}'; the session sees media through media-backend/api.ts.",
},
schema: [],
},
defaultOptions: [],
create(context) {
const filename = context.filename;
const sdkRoot = resolve(context.cwd, "sdk") + sep;
if (!filename.startsWith(sdkRoot)) return {};
if (filename.startsWith(resolve(sdkRoot, "dev") + sep)) return {};
const inBackend = filename.startsWith(
resolve(sdkRoot, "src", "media-backend") + sep,
);
const matchesPackage = (specifier, pkg) =>
specifier === pkg ||
specifier.startsWith(pkg.endsWith("/") ? pkg : `${pkg}/`);
const check = (node, specifier, typeOnly = false) => {
if (typeof specifier !== "string") return;
if (
!inBackend &&
!typeOnly &&
BACKEND_PACKAGES.some((pkg) => matchesPackage(specifier, pkg))
) {
context.report({
node,
messageId: "backendPackage",
data: { specifier },
});
return;
}
if (specifier.startsWith(".")) {
const target = resolve(dirname(filename), specifier);
if (!target.startsWith(sdkRoot))
context.report({
node,
messageId: "outsideSdk",
data: { specifier },
});
} else if (
BANNED_PACKAGES.some((pkg) => matchesPackage(specifier, pkg))
) {
context.report({
node,
messageId: "bannedPackage",
data: { specifier },
});
}
};
return {
ImportDeclaration(node) {
check(
node,
node.source.value,
node.importKind === "type" ||
node.specifiers.every((s) => s.importKind === "type"),
);
},
ExportNamedDeclaration(node) {
if (node.source) check(node, node.source.value);
},
ExportAllDeclaration(node) {
check(node, node.source.value);
},
ImportExpression(node) {
if (node.source.type === "Literal") check(node, node.source.value);
},
};
},
});
export default rule;
+1
View File
@@ -4,5 +4,6 @@ module.exports = {
"no-observablescope-leak": require("./NoObservableScopeLeak").default,
"no-top-level-logger-get-child": require("./NoTopLevelLoggerGetChild")
.default,
"sdk-import-boundary": require("./SdkImportBoundary").default,
},
};
+2
View File
@@ -13,6 +13,8 @@ export default {
"vite.config.ts",
"vite-embedded.config.ts",
"vite-sdk.config.ts",
"vite-sdk-dev.config.ts",
"vite-sdk-target-based-on-call-view-model.config.ts",
"vite-component.config.ts",
"vite-component-dev.config.ts",
],
+4 -4
View File
@@ -59,10 +59,10 @@
"developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"crypto_version": "Crypto version: {{version}}",
"custom_livekit_url": {
"current_url": "Currently set to: ",
"custom_transport": {
"current": "Currently set to: ",
"from_config": "Currently, no overwrite is set. Url from config is used.",
"label": "Custom Livekit-url",
"label": "Custom transport JSON",
"reset": "Reset overwrite",
"save": "Save",
"saving": "Saving..."
@@ -106,7 +106,7 @@
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
"livekit_connection_error": "Failed to connect to Livekit server",
"livekit_connection_error_description": "An error occurred while connecting to the Livekit server (<1>Reason:</1> <2>{{ reason }}</2>).",
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}} and no other call participant provides a usable transport. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"membership_manager": "Membership Manager Error",
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consecutive failed network requests.",
"no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.",
+12 -9
View File
@@ -7,16 +7,20 @@
"dev:full": "vite",
"dev:embedded": "vite --config vite-embedded.config.js",
"dev:component": "vite --config vite-component-dev.config.ts",
"dev:sdk": "vite --config vite-sdk-dev.config.ts",
"build": "pnpm build:full",
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
"build:full:production": "pnpm build:full",
"build:full:development": "pnpm build:full --mode development",
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
"build:embedded:production": "pnpm build:embedded",
"build:embedded:development": "pnpm build:embedded --mode development",
"build:sdk:development": "pnpm build:sdk --mode development",
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
"build:sdk:production": "pnpm build:sdk",
"build:sdk": "pnpm build:sdk:js && pnpm build:sdk:types",
"build:sdk:js": "pnpm build:full --config vite-sdk.config.js",
"build:sdk:types": "tsc -p sdk/tsconfig.build.json",
"build:sdk-target-based-on-call-view-model": "pnpm build:full --config vite-sdk-target-based-on-call-view-model.config.js",
"build:sdk:production": "pnpm build:sdk-target-based-on-call-view-model",
"build:sdk:development": "pnpm build:sdk-target-based-on-call-view-model --mode development",
"build:component": "pnpm build:component:js && pnpm build:component:types",
"build:component:js": "pnpm build:full --config vite-component.config.js",
"build:component:types": "tsc -p component/tsconfig.build.json",
@@ -26,8 +30,8 @@
"format": "oxfmt",
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
"lint:oxlint": "oxlint src component playwright",
"lint:oxlint-fix": "oxlint --fix src component playwright",
"lint:oxlint": "oxlint src component sdk playwright",
"lint:oxlint-fix": "oxlint --fix src component sdk playwright",
"lint:knip": "knip",
"lint:externals": "node scripts/check-component-externals.mjs",
"lint:types": "tsc",
@@ -84,7 +88,7 @@
"@typescript-eslint/utils": "^8.61.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^11.0.0",
"@vector-im/compound-web": "^10.0.0",
"@vector-im/compound-web": "^10.4.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
@@ -103,7 +107,7 @@
"livekit-client": "^2.18.1",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076",
"matrix-widget-api": "^1.18.0",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
@@ -129,7 +133,6 @@
"uuid": "^14.0.0",
"vaul": "^1.0.0",
"vite": "^8.0.0",
"vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-node-polyfills": "^0.28.0",
"vite-plugin-node-stdlib-browser": "^0.2.1",
+27 -2
View File
@@ -12,6 +12,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
import { SDK_HARNESS_URL } from "./playwright/sdk/harness.ts";
const baseURL = process.env.USE_DOCKER
? "http://localhost:8080"
@@ -38,8 +39,20 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/**
* Reporter to use. See https://playwright.dev/docs/test-reporters
*
* On CI, `list` prints a line per test as it finishes, which keeps the job
* log moving; `github` annotates failures on the PR; `html` is uploaded as
* an artifact and must never try to open a browser there.
*/
reporter: process.env.CI
? [["list"], ["github"], ["html", { open: "never" }]]
: [["list"], ["html"]],
/* Stop a broken run early on CI instead of waiting out every retry. */
maxFailures: process.env.CI ? 5 : undefined,
/* Under the job's 60 minute limit, so a hung run still ends with a report. */
globalTimeout: process.env.CI ? 50 * 60 * 1000 : undefined,
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
@@ -146,5 +159,17 @@ export default defineConfig({
timeout: 500,
},
},
{
// The harness that uses the MatrixRTC SDK without Element Call. A Vite
// dev server for the same reason as the component's.
command: "pnpm dev:sdk",
url: SDK_HARNESS_URL,
reuseExistingServer: !process.env.CI,
ignoreHTTPSErrors: true,
gracefulShutdown: {
signal: "SIGTERM",
timeout: 500,
},
},
],
});
+1 -1
View File
@@ -129,7 +129,7 @@ async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
.getByRole("button", { name: "Microphone" })
.click({ timeout: 60_000 });
await expect(page.getByRole("menu")).toBeVisible();
const list = page.locator("[role='menu'] div[role='none']").first();
const list = page.getByRole("menu").getByTestId("device-list");
await expect(list).toBeVisible();
return list;
}
+6
View File
@@ -92,6 +92,12 @@ export async function expectWithin(
outer: Locator,
): Promise<void> {
await expect(inner).toBeVisible();
// Wait for animations to settle.
await inner.evaluate(async (element) => {
for (const animation of element.getAnimations({ subtree: true }))
await animation.finished;
});
const innerBox = await inner.boundingBox();
const outerBox = await outer.boundingBox();
if (innerBox === null || outerBox === null)
+2 -2
View File
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test";
import { createJTWToken } from "./fixtures/jwt-token";
import { createJWTToken } from "./fixtures/jwt-token";
test("Should show error screen if fails to get JWT token", async ({ page }) => {
await page.goto("/");
@@ -95,7 +95,7 @@ test("Should show error screen if call creation is restricted", async ({
contentType: "application/json",
body: JSON.stringify({
url: "wss://badurltotricktest/livekit/sfu",
jwt: createJTWToken("@fake:user", "!fake:room"),
jwt: createJWTToken("@fake:user", "!fake:room"),
}),
}),
);
+1 -1
View File
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
export function createJTWToken(sub: string, room: string): string {
export function createJWTToken(sub: string, room: string): string {
return [
{}, // header
{
+1 -1
View File
@@ -132,7 +132,7 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.getByRole("button", { name: "New", exact: true })
.click();
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
+41
View File
@@ -0,0 +1,41 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { startPair, tileOf } from "./harness";
test.describe.configure({ timeout: 300_000 });
test("a message sent beside the media reaches the peer, and only the peer", async ({
browser,
}) => {
const { pages, users } = await startPair(browser, "sdkdata");
const [sender, receiver] = pages;
// Both are in the call before anything is sent, so the receiver knows the
// sender as a member and does not drop the message
for (const page of pages)
await expect(page.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
await expect(tileOf(receiver, users[0])).toBeVisible();
await sender.getByPlaceholder("message").fill("hello from the SDK");
await sender.getByRole("button", { name: "Send" }).click();
const message = receiver.getByTestId("message");
await expect(message).toHaveText(
`${users[0].displayName}: hello from the SDK`,
{
timeout: 30_000,
},
);
await expect(message).toHaveAttribute("data-topic", "chat");
await expect(message).toHaveAttribute("data-user-id", users[0].userId);
// Our own messages are not echoed back
await expect(sender.getByTestId("message")).toHaveCount(0);
});
+167
View File
@@ -0,0 +1,167 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type Browser,
expect,
type Locator,
type Page,
} from "@playwright/test";
import { SynapseAdmin } from "../utils/synapse-admin.ts";
/**
* Where the SDK harness is served — `sdk/dev`, a page that uses the SDK the way
* a host would, with none of Element Call in it.
*/
export const SDK_HARNESS_URL = "https://localhost:3002";
const HOMESERVER_URL = "https://synapse.m.localhost";
const PASSWORD = "foobarbaz1!";
/** A registered user, with the session the registration opened. */
export interface User {
username: string;
displayName: string;
userId: string;
deviceId: string;
accessToken: string;
}
/**
* Registers two users through the Synapse admin API and has the first create a
* public room, so that the second can join it by id without an invite. The
* registration logs each user in, which spares the harness a `/login` call
* that the homeserver rate-limits.
*/
export async function createUsersAndRoom(
name: string,
): Promise<{ users: [User, User]; roomId: string }> {
const admin = SynapseAdmin.forHomeserver(HOMESERVER_URL);
const users = (await Promise.all(
["a", "b"].map(async (letter, index): Promise<User> => {
const username = `${name}_${letter}_${Date.now()}`;
const displayName = `${name} ${"AB"[index]}`;
const registration = await admin.registerUser(
username,
PASSWORD,
displayName,
);
return {
username,
displayName,
userId: registration.user_id,
deviceId: registration.device_id,
accessToken: registration.access_token,
};
}),
)) as [User, User];
const response = await fetch(
`${HOMESERVER_URL}/_matrix/client/v3/createRoom`,
{
method: "POST",
headers: {
Authorization: `Bearer ${users[0].accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: `${name}'s session`,
preset: "public_chat",
// Per-participant media keys travel in encrypted to-device messages,
// which only reach devices the sender's crypto tracks, and it tracks
// the members of encrypted rooms
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: { algorithm: "m.megolm.v1.aes-sha2" },
},
],
// Every member has to be allowed to write its own membership, which
// a public room does not grant by default; the same levels Element
// Call gives a room it creates for a call
power_level_content_override: {
state_default: 0,
events_default: 0,
users_default: 0,
events: { "org.matrix.msc3401.call.member": 0 },
},
}),
},
);
if (!response.ok)
throw new Error(
`Could not create a room: ${response.status} ${await response.text()}`,
);
const { room_id: roomId } = (await response.json()) as { room_id: string };
return { users, roomId };
}
/**
* Opens the harness signed in as the given user and waits until the SDK
* reports the session as connected. The status line carries the SDK's error
* if it does not get there, so the failure says why.
*/
export async function startHarness(
page: Page,
user: User,
roomId: string,
): Promise<void> {
const query = new URLSearchParams({
homeserver: HOMESERVER_URL,
accessToken: user.accessToken,
userId: user.userId,
deviceId: user.deviceId,
room: roomId,
});
await page.goto(`${SDK_HARNESS_URL}/?${query.toString()}`);
await page.getByRole("button", { name: "Start" }).click();
await waitForConnected(page);
}
/**
* A login, a crypto setup and an initial sync happen before the session can
* connect. An error is final, so it is not worth waiting out the timeout for
* "connected" after one.
*/
export async function waitForConnected(page: Page): Promise<void> {
const status = page.getByTestId("status");
await expect(status).toHaveText(/^(connected|Error)/, { timeout: 120_000 });
const text = await status.textContent();
if (text !== "connected")
throw new Error(`The harness did not connect to the session: ${text}`);
}
/**
* A context of its own per user, since a browser profile holds one login. No
* permissions to grant: each browser is launched with fake media that is
* handed out without asking (see playwright.config.ts).
*/
export async function newPage(browser: Browser): Promise<Page> {
const context = await browser.newContext({ ignoreHTTPSErrors: true });
return context.newPage();
}
/** Two users in one session, each on a page of their own. */
export async function startPair(
browser: Browser,
name: string,
): Promise<{ pages: [Page, Page]; users: [User, User]; roomId: string }> {
const { users, roomId } = await createUsersAndRoom(name);
const pages = await Promise.all([newPage(browser), newPage(browser)]);
await Promise.all(
pages.map(async (page, i) => startHarness(page, users[i], roomId)),
);
return { pages: pages as [Page, Page], users, roomId };
}
/** The tile a page shows for a user. */
export function tileOf(page: Page, user: User): Locator {
return page.locator(`[data-testid="member"][data-user-id="${user.userId}"]`);
}
+67
View File
@@ -0,0 +1,67 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { startPair, tileOf, waitForConnected } from "./harness.ts";
/**
* Members coming and going: a member that leaves disappears for the peer, one
* that comes back is shown again, and the harness is left in the state its
* status line claims.
*/
test.describe.configure({ timeout: 300_000 });
test("a member that leaves disappears for the peer", async ({ browser }) => {
const {
pages: [leaver, stayer],
users: [leaving, staying],
} = await startPair(browser, "sdkleave");
await expect(stayer.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
await leaver.getByRole("button", { name: "Leave" }).click();
await expect(leaver.getByTestId("status")).toHaveText("Left");
await expect(leaver.getByTestId("member")).toHaveCount(0);
await expect(tileOf(stayer, leaving)).toHaveCount(0, { timeout: 60_000 });
await expect(tileOf(stayer, staying)).toHaveCount(1);
await expect(stayer.getByTestId("status")).toHaveText("connected");
});
test("a member that reloads is shown again with its media", async ({
browser,
}) => {
const {
pages: [reloader, watcher],
users: [reloading],
} = await startPair(browser, "sdkreload");
await expect(watcher.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
// The page comes back on the same device, so the new membership replaces
// the old one rather than sitting next to it
await reloader.reload();
await reloader.getByRole("button", { name: "Start" }).click();
await waitForConnected(reloader);
await expect(tileOf(watcher, reloading)).toHaveCount(1, {
timeout: 120_000,
});
const video = tileOf(watcher, reloading).locator("video");
await expect
.poll(async () => video.evaluate((v: HTMLVideoElement) => v.videoWidth), {
timeout: 60_000,
})
.toBeGreaterThan(0);
await expect(reloader.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
});
+186
View File
@@ -0,0 +1,186 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Locator, type Page, test } from "@playwright/test";
import { startPair, tileOf, type User } from "./harness.ts";
/**
* What a member's media looks like from the other side: it plays, it is
* encrypted, it follows the mute switches, and the SFU sends only as much as
* the tile on screen can show. Each test reads the labels the harness puts on
* its media elements, which come straight from the SDK's track behaviors.
*
* One session serves every test here: logging two browsers in and connecting
* them takes most of a minute, and none of the tests leaves the session in a
* state the next cannot start from.
*/
test.describe.configure({ mode: "serial", timeout: 300_000 });
let pages: [Page, Page];
let users: [User, User];
test.beforeAll(async ({ browser }) => {
({ pages, users } = await startPair(browser, "sdkmedia"));
for (const page of pages)
await expect(page.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
});
test.afterAll(async () => {
for (const page of pages ?? []) await page.context().close();
});
test("the peer's camera and microphone play", async () => {
const peer = remoteTile(1, 0);
const video = peer.locator("video");
await expect
.poll(async () => video.evaluate((v: HTMLVideoElement) => v.videoWidth), {
timeout: 60_000,
})
.toBeGreaterThan(0);
await expect(video).toHaveJSProperty("paused", false);
const audio = peer.locator("audio");
await expect
.poll(async () =>
audio.evaluate((a: HTMLAudioElement) => a.srcObject !== null),
)
.toBe(true);
await expect(audio).toHaveJSProperty("paused", false);
});
test("the peer's tracks are encrypted", async () => {
const peer = remoteTile(1, 0);
await expect(peer.locator("video")).toHaveAttribute("data-encrypted", "true");
await expect(peer.locator("audio")).toHaveAttribute("data-encrypted", "true");
});
test("muting the microphone and the camera is seen by the peer", async () => {
const [page] = pages;
const peer = remoteTile(1, 0);
const video = peer.locator("video");
const audio = peer.locator("audio");
await expect(video).toHaveAttribute("data-muted", "false");
await expect(audio).toHaveAttribute("data-muted", "false");
await page.getByRole("button", { name: "Microphone" }).click();
await expect(audio).toHaveAttribute("data-muted", "true", {
timeout: 20_000,
});
await expect(video).toHaveAttribute("data-muted", "false");
await page.getByRole("button", { name: "Camera" }).click();
await expect(video).toHaveAttribute("data-muted", "true", {
timeout: 20_000,
});
await page.getByRole("button", { name: "Microphone" }).click();
await page.getByRole("button", { name: "Camera" }).click();
await expect(audio).toHaveAttribute("data-muted", "false", {
timeout: 20_000,
});
await expect(video).toHaveAttribute("data-muted", "false", {
timeout: 20_000,
});
});
test("the peer's microphone is active while it sends sound, and not while muted", async () => {
// Chromium's fake microphone never registers as a speaker at the SFU, and
// Firefox's steady tone does so in about two runs out of three, with or
// without the browser's audio processing. The pipeline is proven when it
// passes; it needs a louder fake device or a lower SFU threshold to be
// relied on.
test.fixme(true, "the fake microphones do not reliably register at the SFU");
const [page] = pages;
const audio = remoteTile(1, 0).locator("audio");
// The SFU only announces a change of speakers, so a tone that has been
// playing since before the peer subscribed is not announced to it. Muting
// and unmuting is the change that is.
await page.getByRole("button", { name: "Microphone" }).click();
await expect(audio).toHaveAttribute("data-muted", "true", {
timeout: 20_000,
});
await expect(audio).toHaveAttribute("data-active", "false");
await page.getByRole("button", { name: "Microphone" }).click();
await expect(audio).toHaveAttribute("data-active", "true", {
timeout: 30_000,
});
await page.getByRole("button", { name: "Microphone" }).click();
await expect(audio).toHaveAttribute("data-active", "false", {
timeout: 20_000,
});
await page.getByRole("button", { name: "Microphone" }).click();
});
test("a larger tile receives a higher resolution", async () => {
const peer = remoteTile(1, 0);
const video = peer.locator("video");
// The tile starts small enough for the lowest simulcast layer
const small = await receivedWidth(video, (width) => width > 0);
await resize(peer, 1280);
const large = await receivedWidth(video, (width) => width > small);
expect(large).toBeGreaterThan(small);
await resize(peer, 240);
await receivedWidth(video, (width) => width < large);
});
test("a hidden tile stops receiving video, and a shown one resumes", async () => {
const peer = remoteTile(1, 0);
const video = peer.locator("video");
await expect(frames(video)).resolves.toBeGreaterThan(0);
await peer.evaluate((tile) => (tile.style.display = "none"));
await expect
.poll(async () => framesStill(video), { timeout: 60_000 })
.toBe(true);
await peer.evaluate((tile) => (tile.style.display = ""));
await expect
.poll(async () => !(await framesStill(video)), { timeout: 60_000 })
.toBe(true);
});
/** The tile on one page for the user of the other page. */
function remoteTile(viewer: 0 | 1, shown: 0 | 1): Locator {
return tileOf(pages[viewer], users[shown]);
}
async function resize(tile: Locator, width: number): Promise<void> {
await tile.evaluate((t, w) => (t.style.width = `${w}px`), width);
}
/** The width of the video as decoded, once it satisfies the condition. */
async function receivedWidth(
video: Locator,
until: (width: number) => boolean,
): Promise<number> {
// Switching simulcast layers takes the SFU a few seconds
let width = 0;
await expect
.poll(
async () => {
width = await video.evaluate((v: HTMLVideoElement) => v.videoWidth);
return until(width);
},
{ timeout: 60_000 },
)
.toBe(true);
return width;
}
async function frames(video: Locator): Promise<number> {
return Number(await video.getAttribute("data-frames"));
}
/** Whether no frame arrived over a couple of seconds. */
async function framesStill(video: Locator): Promise<boolean> {
const before = await frames(video);
await new Promise((resolve) => setTimeout(resolve, 2500));
return (await frames(video)) === before;
}
+32
View File
@@ -0,0 +1,32 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { startPair, tileOf } from "./harness.ts";
/**
* The MatrixRTC SDK driven through its harness in `sdk/dev`: no Element Call
* on the page, only the SDK's public API.
*/
// Two browsers each log in, set up crypto and sync before anything is on
// screen, then wait for media to connect
test.describe.configure({ timeout: 300_000 });
test("two browsers see each other in one session", async ({ browser }) => {
const { pages, users } = await startPair(browser, "sdksmoke");
// Each page shows itself and the other, each tile named by its user
for (const page of pages) {
await expect(page.getByTestId("member")).toHaveCount(2, {
timeout: 60_000,
});
for (const user of users)
await expect(tileOf(page, user)).toContainText(user.displayName);
}
});
+493
View File
@@ -0,0 +1,493 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Page, test } from "@playwright/test";
import { SpaHelpers } from "./spa-helpers";
import { type RtcMode } from "./widget/test-helpers.ts";
const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu";
const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt";
const otherHomeserver = {
base_url: "https://synapse.othersite.m.localhost",
server_name: "synapse.othersite.m.localhost",
};
const otherSfuUrl = "wss://matrix-rtc.othersite.m.localhost/livekit/sfu";
interface Transport {
type: "livekit";
url?: string;
livekit_service_url?: string;
}
/**
* The transport shapes a homeserver may advertise while migrating to the
* latest MSC4195. `legacy` is what the e2e Synapse advertises on its own.
*/
const shapes = {
url: { type: "livekit", url: sfuUrl },
both: { type: "livekit", url: sfuUrl, livekit_service_url: serviceUrl },
legacy: null,
} satisfies Record<string, Transport | null>;
type Shape = keyof typeof shapes;
/** Which endpoint a page asks for its SFU tokens. */
type Flow = "cs-api" | "service-legacy" | "service-default";
const cases: {
hostShape: Shape;
guestShape: Shape;
/** The MatrixRTC mode both sides use; the default is compatibility mode. */
mode?: RtcMode;
hostFlows: Flow[];
guestFlows: Flow[];
}[] = [
{
hostShape: "url",
guestShape: "url",
hostFlows: ["cs-api"],
guestFlows: ["cs-api"],
},
{
// Publishers keep the legacy participant identity that clients predating
// MSC4195 expect, which only the JWT service assigns.
hostShape: "both",
guestShape: "both",
hostFlows: ["service-legacy"],
guestFlows: ["service-legacy"],
},
{
// The MatrixRTC mode has no bearing on the choice.
hostShape: "both",
guestShape: "both",
mode: "2_0",
hostFlows: ["service-legacy"],
guestFlows: ["service-legacy"],
},
{
hostShape: "url",
guestShape: "legacy",
hostFlows: ["cs-api", "service-default"],
guestFlows: ["service-legacy", "cs-api"],
},
];
for (const { hostShape, guestShape, mode, hostFlows, guestFlows } of cases) {
test(`One to one call with ${hostShape} host and ${guestShape} guest transport${mode === "2_0" ? " in Matrix 2.0 mode" : ""}`, async ({
browser,
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host starts the call.
await advertiseTransport(page, shapes[hostShape]);
const hostRequests = recordAuthRequests(page);
await page.goto("/");
await SpaHelpers.createCall(page, "Androl", "HelloCall", true, mode);
const inviteLink = await SpaHelpers.getCallInviteLink(page);
// The guest joins.
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guestPage = await guestContext.newPage();
await advertiseTransport(guestPage, shapes[guestShape]);
const guestRequests = recordAuthRequests(guestPage);
await SpaHelpers.joinCallFromInviteLink(
guestPage,
inviteLink,
"Pevara",
mode,
);
// Wait for the call to connect and render.
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
// The host and guest ran through the expected flows.
expect(flowsOf(hostRequests)).toEqual(new Set(hostFlows));
expect(flowsOf(guestRequests)).toEqual(new Set(guestFlows));
// All requests succeeded and the SFU URL was set on C-S requests.
for (const request of [...hostRequests, ...guestRequests]) {
expect(request.status).toBe(200);
if (request.flow === "cs-api") expect(request.sfuUrl).toBe(sfuUrl);
// Clients predating MSC4195 match a publisher by the legacy identity,
// which only the JWT service's legacy endpoint assigns. The other flows
// hash it.
expect(request.identity).toMatch(
request.flow === "service-legacy" ? legacyIdentity : hashedIdentity,
);
}
});
}
test("Subscriber falls back to the JWT service when the homeserver lacks MSC4195", async ({
browser,
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host starts the call with both transport shapes.
await advertiseTransport(page, shapes.both);
await page.goto("/");
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
const inviteLink = await SpaHelpers.getCallInviteLink(page);
// The guest joins but their C-S requests fail.
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guestPage = await guestContext.newPage();
await rejectCsApiTokens(guestPage);
const guestRequests = recordAuthRequests(guestPage);
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
// Wait for the call to connect and render.
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
// The guest ran through all flows.
expect(flowsOf(guestRequests)).toEqual(
new Set<Flow>(["cs-api", "service-legacy", "service-default"]),
);
// The C-S token requests failed.
for (const request of guestRequests) {
expect(request.status).toBe(request.flow === "cs-api" ? 404 : 200);
}
// The C-S token requests preceeded the fallback request.
const flows = guestRequests.map((r) => r.flow);
expect(flows.indexOf("service-default")).toBeGreaterThan(
flows.indexOf("cs-api"),
);
});
test("Publisher shows an error when the homeserver lacks MSC4195", async ({
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host tries to start a call with only the C-S transport shape available but
// their C-S requests fail.
await advertiseTransport(page, shapes.url);
await rejectCsApiTokens(page);
await page.goto("/");
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
// We land on an error page.
await expect(
page.getByRole("heading", { name: "Something went wrong" }),
).toBeVisible();
await expect(
page.getByText(
"The authorization service for your media server (SFU) is out of date.",
),
).toBeVisible();
});
/**
* Delegation follows the flow that authenticated the publisher: the homeserver
* for a `url`-only transport, the JWT service wherever there is one.
*/
const delegationCases: { shape: Shape; via: Delegation["via"] }[] = [
{ shape: "url", via: "homeserver" },
{ shape: "legacy", via: "service" },
{ shape: "both", via: "service" },
];
for (const { shape, via } of delegationCases) {
test(`Delayed leave event delegated through the ${via} for a ${shape} transport gets sent on disconnect`, async ({
browser,
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host starts the call.
await advertiseTransport(page, shapes[shape]);
await page.goto("/");
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
const inviteLink = await SpaHelpers.getCallInviteLink(page);
// The guest joins.
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guestPage = await guestContext.newPage();
await advertiseTransport(guestPage, shapes[shape]);
const delayedLeave = recordDelayedLeave(guestPage);
const delegations = recordDelegations(guestPage);
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
// Wait for the call to connect and render.
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
// The guest handed its delayed leave over, after probing for delegation
// support with bodiless requests, which count as supported unless they
// 404. The homeserver is always probed; the JWT service only when there
// is one.
await expect
.poll(() => delegations.at(-1))
.toEqual({
via,
status: 200,
delayId: delayedLeave.delayId,
...(via === "homeserver" && { sfuUrl }),
});
const probes = delegations.slice(0, -1);
expect(probes.some((probe) => probe.via === via)).toBe(true);
for (const probe of probes) {
expect(probe.status).toBeGreaterThanOrEqual(400);
expect(probe.status).not.toBe(404);
}
// The delay is long enough that a leave within the window below cannot
// come from the delayed event expiring on its own.
expect(delayedLeave.delayMs).toBeGreaterThan(60_000);
// The guest drops without leaving.
await guestPage.reload();
// The host eventually sees the guest go once the delayed event fires.
await expect(page.getByTestId("videoTile")).toHaveCount(1, {
timeout: 30_000,
});
});
}
test("Subscribers get tokens for a remote homeserver's SFU over federation", async ({
browser,
page,
browserName,
}) => {
skipOnFirefox(browserName);
// The host lives on the other homeserver and publishes on its SFU.
await useHomeserver(page, otherHomeserver);
await advertiseTransport(page, { type: "livekit", url: otherSfuUrl });
const hostRequests = recordAuthRequests(page);
await page.goto("/");
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
const inviteLink = await SpaHelpers.getCallInviteLink(page);
// The guest lives on the default homeserver and joins over federation.
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guestPage = await guestContext.newPage();
await advertiseTransport(guestPage, shapes.url);
const guestRequests = recordAuthRequests(guestPage);
await SpaHelpers.joinCallFromInviteLink(
guestPage,
`${inviteLink}&viaServers=${otherHomeserver.server_name}`,
"Pevara",
);
// Wait for the call to connect and render.
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
// Each side got a token for its own SFU and for the other side's SFU, all
// through its own homeserver.
for (const requests of [hostRequests, guestRequests]) {
expect(flowsOf(requests)).toEqual(new Set<Flow>(["cs-api"]));
expect(new Set(requests.map((r) => r.sfuUrl))).toEqual(
new Set([sfuUrl, otherSfuUrl]),
);
for (const request of requests) expect(request.status).toBe(200);
}
});
/** Skips the calling test if we're running on Firefox. */
function skipOnFirefox(browserName: string): void {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
}
/** Points this page's Element Call at another homeserver. */
async function useHomeserver(
page: Page,
homeserver: typeof otherHomeserver,
): Promise<void> {
await page.route("**/config.json", async (route) => {
const response = await route.fetch();
const config = (await response.json()) as Record<string, unknown>;
await route.fulfill({
response,
json: {
...config,
default_server_config: { "m.homeserver": homeserver },
},
});
});
}
/**
* Maps recorded requests to a set of flows. Publisher and subscriber connections
* start concurrently so using arrays for comparison isn't convenient.
*/
function flowsOf(requests: AuthRequest[]): Set<Flow> {
return new Set(requests.map((r) => r.flow));
}
/**
* Replaces the transports the homeserver advertises to this page. The real
* response is fetched first so its CORS headers carry over.
*/
async function advertiseTransport(
page: Page,
transport: Transport | null,
): Promise<void> {
if (transport === null) return;
await page.route("**/org.matrix.msc4143/rtc/transports", async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
await route.fulfill({ response, json: { rtc_transports: [transport] } });
});
}
/**
* Makes the homeserver look like one without MSC4195: its `get_token`
* endpoint answers as Synapse does for an unknown route.
*/
async function rejectCsApiTokens(page: Page): Promise<void> {
await page.route("**/rtc/livekit/get_token", async (route) => {
if (route.request().method() !== "POST") return route.continue();
await route.fulfill({
status: 404,
json: { errcode: "M_UNRECOGNIZED", error: "Unrecognized request" },
headers: { "Access-Control-Allow-Origin": "*" },
});
});
}
/** A recorded delayed leave event. */
interface DelayedLeave {
delayMs?: number;
delayId?: string;
}
/** Records the delayed leave event the page schedules on joining. */
function recordDelayedLeave(page: Page): DelayedLeave {
const delayedLeave: DelayedLeave = {};
page.on("response", (response) => {
const delay = new URL(response.request().url()).searchParams.get(
"org.matrix.msc4140.delay",
);
if (delay === null || response.status() !== 200) return;
delayedLeave.delayMs = Number(delay);
response
.json()
.then((body: { delay_id: string }) => {
delayedLeave.delayId = body.delay_id;
})
.catch(() => {});
});
return delayedLeave;
}
/** A recorded delegation request for a delayed leave event. */
interface Delegation {
/** Who was asked to send the leave event on the guest's behalf. */
via: "homeserver" | "service";
status: number;
sfuUrl?: string;
delayId?: string;
}
/**
* Records requests that delegate a delayed leave event, or probe for support:
* the MSC4195 `delegate_delayed_leave` endpoint of the homeserver, and the JWT
* service's `delegate_delayed_leave` probe and `/sfu/get` with delay fields.
*/
function recordDelegations(page: Page): Delegation[] {
const delegations: Delegation[] = [];
page.on("response", (response) => {
const request = response.request();
if (request.method() !== "POST") return;
const url = request.url();
const body = request.postDataJSON() as {
url?: string;
delay_id?: string;
} | null;
if (url.endsWith("/rtc/livekit/delegate_delayed_leave")) {
delegations.push({
via: "homeserver",
status: response.status(),
...(body && { sfuUrl: body.url, delayId: body.delay_id }),
});
} else if (url.endsWith("/livekit/jwt/delegate_delayed_leave")) {
delegations.push({ via: "service", status: response.status() });
} else if (url.endsWith("/livekit/jwt/sfu/get") && body?.delay_id) {
delegations.push({
via: "service",
status: response.status(),
delayId: body.delay_id,
});
}
});
return delegations;
}
/** A recorded token request. */
interface AuthRequest {
flow: Flow;
status: number;
/** The SFU a request to the MSC4195 `get_token` endpoint asked for. */
sfuUrl?: string;
/** The participant identity the issued token carries. */
identity?: string;
}
/** The legacy `<user_id>:<device_id>` participant identity. */
const legacyIdentity = /^@[^:]+:[^:]+:[A-Z]+$/;
/** A hashed participant identity: unpadded base64 of a SHA-256. */
const hashedIdentity = /^[A-Za-z0-9+/]{43}$/;
/** Reads the subject of a JWT without verifying it. */
function jwtSubject(jwt: string): string {
const [, payload] = jwt.split(".");
return (
JSON.parse(Buffer.from(payload, "base64url").toString()) as {
sub: string;
}
).sub;
}
/** Records token requests, including the identity each token carries. */
function recordAuthRequests(page: Page): AuthRequest[] {
const requests: AuthRequest[] = [];
page.on("response", (response) => {
const request = response.request();
if (request.method() !== "POST") return;
const url = request.url();
let entry: AuthRequest;
if (url.endsWith("/rtc/livekit/get_token")) {
entry = {
flow: "cs-api",
status: response.status(),
sfuUrl: (request.postDataJSON() as { url: string }).url,
};
} else if (url.endsWith("/livekit/jwt/sfu/get")) {
entry = { flow: "service-legacy", status: response.status() };
} else if (url.endsWith("/livekit/jwt/get_token")) {
entry = { flow: "service-default", status: response.status() };
} else {
return;
}
requests.push(entry);
if (entry.status !== 200) return;
response
.json()
.then((body: { jwt: string }) => {
entry.identity = jwtSubject(body.jwt);
})
.catch(() => {});
});
return requests;
}
-4
View File
@@ -37,8 +37,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
await TestHelpers.acceptRoomInvite(roomName, timo.page);
await florian.page.pause();
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page,
rtcMode1,
@@ -74,8 +72,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
// await florian.page.pause();
},
);
});
+6 -6
View File
@@ -76,9 +76,9 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
timeout: 5000,
});
await expect(
frame.locator('video[data-lk-source="screen_share"]'),
).toHaveCount(1);
await expect(frame.locator('video[data-source="screenShare"]')).toHaveCount(
1,
);
}
// Alice should be in grid mode as she is local sharing
@@ -117,9 +117,9 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
timeout: 5000,
});
await expect(
frame.locator('video[data-lk-source="screen_share"]'),
).toHaveCount(2);
await expect(frame.locator('video[data-source="screenShare"]')).toHaveCount(
2,
);
// Expect 2 indicators at the bottom
await expect(frame.getByTestId("screenshare-indicator")).toHaveCount(2);
+1 -1
View File
@@ -223,7 +223,7 @@ export class TestHelpers {
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.getByRole("button", { name: "New", exact: true })
.click();
await page.getByRole("menuitem", { name: "New Room" }).click({
+104
View File
@@ -0,0 +1,104 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Page, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user.ts";
import { HOST1, TestHelpers } from "./test-helpers.ts";
const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu";
const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt";
/** Which endpoint a page asks for its SFU tokens. */
type Flow = "cs-api" | "service-legacy" | "service-default";
widgetTest(
"Call between a url + livekit_service_url transport and a legacy one",
async ({ addUser, browserName }) => {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow();
const [host, guest] = await Promise.all([
addUser("Androl", HOST1),
addUser("Pevara", HOST1),
]);
// The host's homeserver has started advertising a `url` next to its JWT
// service; the guest's still advertises the JWT service alone.
await advertiseTransport(host.page, {
type: "livekit",
url: sfuUrl,
livekit_service_url: serviceUrl,
});
// Element Web answers the widget from a cache it filled at login, so make
// it fetch the transports again now that they are rerouted
await host.clientHandle.evaluate(async (client) => {
await client.cachedRtcTransports.refresh();
});
const hostFlows = recordFlows(host.page);
const guestFlows = recordFlows(guest.page);
const roomName = "Transport Room";
await TestHelpers.createRoom(roomName, host.page, [guest.mxId]);
await TestHelpers.acceptRoomInvite(roomName, guest.page);
await TestHelpers.startCallInCurrentRoom(host.page, false);
await TestHelpers.joinCallFromLobby(host.page);
await TestHelpers.joinCallInCurrentRoom(guest.page);
for (const user of [host, guest]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
timeout: 10000,
});
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
timeout: 10000,
});
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
// Element Web used for Playwright does support MSC4195 for widgets.
expect(hostFlows).toEqual(new Set(["service-legacy", "service-default"]));
expect(guestFlows).toEqual(new Set(["service-legacy", "cs-api"]));
},
);
/**
* Replaces the transports the homeserver advertises to this page, including
* to the widget host, which fetches them on the widget's behalf. The real
* response is fetched first so its CORS headers carry over.
*/
async function advertiseTransport(
page: Page,
transport: { type: "livekit"; url?: string; livekit_service_url?: string },
): Promise<void> {
await page.route("**/org.matrix.msc4143/rtc/transports", async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
await route.fulfill({ response, json: { rtc_transports: [transport] } });
});
}
/** Records which token endpoints the page's widget asks. */
function recordFlows(page: Page): Set<Flow> {
const flows = new Set<Flow>();
page.on("response", (response) => {
const request = response.request();
if (request.method() !== "POST") return;
const url = request.url();
if (url.endsWith("/rtc/livekit/get_token")) flows.add("cs-api");
else if (url.endsWith("/livekit/jwt/sfu/get")) flows.add("service-legacy");
else if (url.endsWith("/livekit/jwt/get_token"))
flows.add("service-default");
});
return flows;
}
+60 -95
View File
@@ -132,10 +132,10 @@ importers:
version: 10.3.1(react@19.2.8)
'@vector-im/compound-design-tokens':
specifier: ^11.0.0
version: 11.0.0(@types/react@19.2.18)(react@19.2.8)
version: 11.2.0(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-web':
specifier: ^10.0.0
version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
specifier: ^10.4.0
version: 10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.2.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
@@ -191,11 +191,11 @@ importers:
specifier: ^1.9.1
version: 1.9.2
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#develop
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a
specifier: github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076
matrix-widget-api:
specifier: ^1.18.0
version: 1.19.0
version: 1.20.0
node-stdlib-browser:
specifier: ^1.3.1
version: 1.3.1
@@ -222,7 +222,7 @@ importers:
version: 10.6.1(postcss@8.5.26)
postcss-selector-parser:
specifier: ^7.1.1
version: 7.1.3
version: 7.1.6
posthog-js:
specifier: 1.408.2
version: 1.408.2
@@ -268,9 +268,6 @@ importers:
vite:
specifier: ^8.0.0
version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)
vite-plugin-generate-file:
specifier: ^0.3.0
version: 0.3.1
vite-plugin-html:
specifier: ^3.2.2
version: 3.2.2(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
@@ -1194,8 +1191,8 @@ packages:
'@types/dom-mediacapture-transform': ^0.1.9
livekit-client: ^1.12.0 || ^2.1.0
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0':
resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==}
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
engines: {node: '>= 18'}
'@mdx-js/react@3.1.1':
@@ -3226,8 +3223,8 @@ packages:
peerDependencies:
react: '>= 16.8.0'
'@vector-im/compound-design-tokens@11.0.0':
resolution: {integrity: sha512-pWz0Q67Xbo8gwzaTeFbTTcM6i29ju6PR7SqyuExr2nIV3j9K785cOInft8FA2CM7PEapkr6TRtJKR/4xtWh2RA==}
'@vector-im/compound-design-tokens@11.2.0':
resolution: {integrity: sha512-XGwO63ZqFVXdkhCVLm3wa/tpx2Lw8pyaQYskObrLZIUkjs3aUmgq8CNhGcjkqRJXYGsLNJE2azov4DaM74h2PQ==}
peerDependencies:
'@adobe/leonardo-contrast-colors': ^1.0.0
'@types/react': '*'
@@ -3243,13 +3240,13 @@ packages:
react:
optional: true
'@vector-im/compound-web@10.1.0':
resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==}
'@vector-im/compound-web@10.4.0':
resolution: {integrity: sha512-Tt5hsNK7ulhGdRuy9lzSm5C+T8M/4O4kLDFLkaU+Hi1kVWj6tj/5awW+U+GvtpWRh5llbHN2HRCh79+gQhek+A==}
peerDependencies:
'@fontsource/inconsolata': ^5
'@fontsource/inter': ^5
'@types/react': '*'
'@vector-im/compound-design-tokens': '>=1.6.1 <11.0.0'
'@vector-im/compound-design-tokens': '>=1.6.1 <12.0.0'
react: ^18 || ^19.0.0
peerDependenciesMeta:
'@types/react':
@@ -3659,8 +3656,8 @@ packages:
constants-browserify@1.0.0:
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
content-type@3.0.0:
resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==}
content-type@3.1.1:
resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==}
engines: {node: '>=22'}
convert-source-map@2.0.0:
@@ -4465,10 +4462,6 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
@@ -4680,13 +4673,13 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
resolution: {gitHosted: true, integrity: sha512-0EGwbXzvG88sBOxq4ZlCRwT4VpQj2xkizfyy0A3bTSK0ysb6/gXJ/o1WPFoqCjRGMDgPq4gAsk92xGtHA7EhPg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a}
version: 42.2.0
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
resolution: {gitHosted: true, integrity: sha512-Ly8eS7fNNKDo9KmPvTkE49KvD8iMUmrVWJMNERNuxc8UksMLMG36NLz3fs8HytdwrB7AKPfyVo392VZsxILDIg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076}
version: 44.0.0
engines: {node: '>=22.0.0'}
matrix-widget-api@1.19.0:
resolution: {integrity: sha512-Qe7L6G1mQJphBC47/Bzp5ljPIK1UARgLiuGgS+/KmEs/eaNHkQ3+ArD0/0kvMV+d6kCGAZ9OKRqsnuPjizI+tw==}
matrix-widget-api@1.20.0:
resolution: {integrity: sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==}
md5.js@1.3.5:
resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
@@ -4703,14 +4696,6 @@ packages:
resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==}
hasBin: true
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mimic-function@5.0.1:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
@@ -4895,8 +4880,8 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-retry@8.0.0:
resolution: {integrity: sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==}
p-retry@8.0.1:
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
engines: {node: '>=22'}
p-try@2.2.0:
@@ -5175,8 +5160,8 @@ packages:
peerDependencies:
postcss: ^8.4
postcss-selector-parser@7.1.3:
resolution: {integrity: sha512-ajnd7iZnqjJDkyHNfznl/ZVO0lWqvBmQXfKKENx9/p/bEiF/L3eHwdydNUg9RXZx6xfZWOCmXmBa5oeB+YrAPQ==}
postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'}
postcss-value-parser@4.2.0:
@@ -5904,9 +5889,6 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
vite-plugin-generate-file@0.3.1:
resolution: {integrity: sha512-tiA3gkPM21MS2+RyqsBMT33GSlM9LM1TJjf6vGvV/e/ml3e3vTKfuH3l2N0NpUgcayvj1fXnmlo5YBuahA6bsg==}
vite-plugin-html@3.2.2:
resolution: {integrity: sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q==}
peerDependencies:
@@ -6415,9 +6397,9 @@ snapshots:
'@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)':
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
'@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)':
dependencies:
@@ -6523,9 +6505,9 @@ snapshots:
'@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)':
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
'@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)':
dependencies:
@@ -6627,7 +6609,7 @@ snapshots:
'@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)':
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
'@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)':
dependencies:
@@ -6671,13 +6653,13 @@ snapshots:
dependencies:
postcss: 8.5.26
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.3)':
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.6)':
dependencies:
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.3)':
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.6)':
dependencies:
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
'@csstools/utilities@2.0.0(postcss@8.5.26)':
dependencies:
@@ -7101,7 +7083,7 @@ snapshots:
'@types/dom-mediacapture-transform': 0.1.11
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)':
dependencies:
@@ -8685,12 +8667,12 @@ snapshots:
'@use-gesture/core': 10.3.1
react: 19.2.8
'@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8)':
'@vector-im/compound-design-tokens@11.2.0(@types/react@19.2.18)(react@19.2.8)':
optionalDependencies:
'@types/react': 19.2.18
react: 19.2.8
'@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
'@vector-im/compound-web@10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.2.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -8702,7 +8684,7 @@ snapshots:
'@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-design-tokens': 11.0.0(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-design-tokens': 11.2.0(@types/react@19.2.18)(react@19.2.8)
classnames: 2.5.1
react: 19.2.8
vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -9163,7 +9145,7 @@ snapshots:
constants-browserify@1.0.0: {}
content-type@3.0.0: {}
content-type@3.1.1: {}
convert-source-map@2.0.0: {}
@@ -9234,13 +9216,13 @@ snapshots:
css-blank-pseudo@7.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
css-has-pseudo@7.0.3(postcss@8.5.26):
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-value-parser: 4.2.0
css-prefers-color-scheme@10.0.0(postcss@8.5.26):
@@ -10034,10 +10016,6 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@4.3.0:
dependencies:
argparse: 2.0.1
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -10249,21 +10227,21 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
dependencies:
'@babel/runtime': 8.0.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.5.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
another-json: 0.2.0
bs58: 6.0.0
content-type: 3.0.0
content-type: 3.1.1
loglevel: 1.9.2
matrix-events-sdk: 0.0.1
matrix-widget-api: 1.19.0
p-retry: 8.0.0
matrix-widget-api: 1.20.0
p-retry: 8.0.1
sdp-transform: 3.0.0
unhomoglyph: 1.0.6
matrix-widget-api@1.19.0:
matrix-widget-api@1.20.0:
dependencies:
'@types/events': 3.0.3
events: 3.3.0
@@ -10286,12 +10264,6 @@ snapshots:
bn.js: 4.12.3
brorand: 1.1.0
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mimic-function@5.0.1: {}
min-indent@1.0.1: {}
@@ -10584,7 +10556,7 @@ snapshots:
dependencies:
p-limit: 3.1.0
p-retry@8.0.0:
p-retry@8.0.1:
dependencies:
is-network-error: 1.3.2
@@ -10694,7 +10666,7 @@ snapshots:
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-clamp@4.1.0(postcss@8.5.26):
dependencies:
@@ -10745,12 +10717,12 @@ snapshots:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-dir-pseudo-class@9.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-double-position-gradients@6.0.4(postcss@8.5.26):
dependencies:
@@ -10762,12 +10734,12 @@ snapshots:
postcss-focus-visible@10.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-focus-within@9.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-font-variant@5.0.0(postcss@8.5.26):
dependencies:
@@ -10799,10 +10771,10 @@ snapshots:
postcss-nesting@13.0.2(postcss@8.5.26):
dependencies:
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.6)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-opacity-percentage@3.0.0(postcss@8.5.26):
dependencies:
@@ -10900,7 +10872,7 @@ snapshots:
postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26):
dependencies:
@@ -10909,9 +10881,9 @@ snapshots:
postcss-selector-not@8.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-selector-parser@7.1.3:
postcss-selector-parser@7.1.6:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
@@ -11652,13 +11624,6 @@ snapshots:
- '@types/react'
- '@types/react-dom'
vite-plugin-generate-file@0.3.1:
dependencies:
ejs: 3.1.10
js-yaml: 4.3.0
mime-types: 2.1.35
picocolors: 1.1.1
vite-plugin-html@3.2.2(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)):
dependencies:
'@rollup/pluginutils': 4.2.1
+94 -72
View File
@@ -6,50 +6,72 @@ Please see LICENSE in the repository root for full details.
*/
/**
* Checks that the component build leaves the packages a host must supply to
* the host.
* Checks that the library builds leave the packages a host must supply to the
* host.
*
* A host application already has React, the Matrix SDK and LiveKit, and a
* second copy of any of them is worse than dead weight: React would hold two
* sets of hooks, and the Matrix client would run two sync loops. So the
* component build lists them as external — but that list has to name every
* subpath, since the bundler silently ignores the pattern and callback forms
* of the option, and an import it does not cover is bundled with no warning at
* all. That is the failure this guards against.
* A host application already has React, the Matrix SDK and Compound,
* and a second copy of any of them is worse than dead weight: React would hold
* two sets of hooks, the Matrix client would run two sync loops, and a second
* Compound would style the tooltips it floats into the host's body with class
* names the host's stylesheet does not know. So each library build lists them
* as external — but that list has to name every subpath, since the bundler
* silently ignores the pattern and callback forms of the option, and an import
* it does not cover is bundled with no warning at all. That is the failure
* this guards against.
*
* It reads the list from the build config itself, so there is one copy of it,
* and compares it against every import of those packages in the source.
* It reads each list from the build config itself, so there is one copy of it
* per build, and compares it against every import of those packages in the
* source.
*
* The comparison is deliberately over-approximate: it looks at all of `src`
* rather than only the modules the component actually pulls in, so it will
* sometimes ask for a subpath that only the standalone app imports. Listing
* one the component never imports costs nothing — the bundler ignores it —
* whereas missing one costs a duplicate package.
* rather than only the modules a build actually pulls in, so it will sometimes
* ask for a subpath that only the standalone app imports. Listing one a build
* never imports costs nothing — the bundler ignores it — whereas missing one
* costs a duplicate package.
*/
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { loadConfigFromFile } from "vite";
const CONFIG = "vite-component.config.ts";
const SOURCES = ["src", "component"];
/** The packages whose duplication would break a host, rather than merely enlarge it. */
const MUST_BE_EXTERNAL = [
"react",
"react-dom",
"matrix-js-sdk",
"livekit-client",
/**
* The library builds, each with the directories it is built from and the
* packages whose duplication would break a host rather than merely enlarge it.
* A development harness is a host, not part of the library: it is the one that
* imports what a host supplies (Compound's stylesheets, say).
*/
const TARGETS = [
{
config: "vite-component.config.ts",
what: "the component build",
sources: ["src", "component"],
excluded: ["component/dev"],
mustBeExternal: [
"react",
"react-dom",
"matrix-js-sdk",
"@vector-im/compound-web",
"@vector-im/compound-design-tokens",
],
},
{
config: "vite-sdk.config.ts",
what: "the SDK build",
sources: ["src", "sdk"],
excluded: ["sdk/dev"],
mustBeExternal: ["matrix-js-sdk", "livekit-client", "rxjs"],
},
];
const isTestFile = (name) =>
name.includes(".test.") || name.includes(".stories.");
/** Every source file under the given directories, recursively. */
async function* sourceFiles(dir) {
async function* sourceFiles(dir, excluded) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) yield* sourceFiles(path);
if (excluded.includes(path)) continue;
if (entry.isDirectory()) yield* sourceFiles(path, excluded);
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
yield path;
}
@@ -81,60 +103,60 @@ function imports(source) {
* with the host's copy of anything. Worker sub-builds do not inherit this
* option anyway.
*/
const mustBeExternal = (specifier) =>
const mustBeExternal = (specifier, packages) =>
!specifier.includes("?") &&
MUST_BE_EXTERNAL.some(
(pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`),
);
packages.some((pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`));
const loaded = await loadConfigFromFile(
{ command: "build", mode: "production" },
CONFIG,
);
if (loaded === null) {
console.error(`Could not load ${CONFIG}`);
process.exit(1);
}
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
if (declared.size === 0) {
console.error(
`${CONFIG} declares nothing external. Either the option moved, or the ` +
`list is empty; either way this check is not looking at what it thinks.`,
/** @returns Whether the target's externals list covers its imports. */
async function check({
config,
what,
sources,
excluded,
mustBeExternal: packages,
}) {
const loaded = await loadConfigFromFile(
{ command: "build", mode: "production" },
config,
);
process.exit(1);
}
// Where each missing specifier is imported, so the message can point at it
const missing = new Map();
const seen = new Set();
for (const dir of SOURCES)
for await (const file of sourceFiles(dir)) {
const source = await readFile(file, "utf8");
for (const specifier of imports(source)) {
if (!mustBeExternal(specifier)) continue;
seen.add(specifier);
if (declared.has(specifier)) continue;
const files = missing.get(specifier) ?? [];
files.push(file);
missing.set(specifier, files);
}
if (loaded === null) {
console.error(`Could not load ${config}`);
return false;
}
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
if (declared.size === 0) {
console.error(
`${config} declares nothing external. Either the option moved, or the ` +
`list is empty; either way this check is not looking at what it thinks.`,
);
return false;
}
if (missing.size > 0) {
// Where each missing specifier is imported, so the message can point at it
const missing = new Map();
for (const dir of sources)
for await (const file of sourceFiles(dir, excluded)) {
const source = await readFile(file, "utf8");
for (const specifier of imports(source)) {
if (!mustBeExternal(specifier, packages)) continue;
if (declared.has(specifier)) continue;
const files = missing.get(specifier) ?? [];
files.push(file);
missing.set(specifier, files);
}
}
if (missing.size === 0) return true;
console.error(
`${CONFIG} does not declare these imports external, so the component ` +
`build would bundle its own copy of them:\n`,
`${config} does not declare these imports external, so ${what} ` +
`would bundle its own copy of them:\n`,
);
for (const [specifier, files] of [...missing].sort())
console.error(` ${specifier}\n imported by ${files.join(", ")}`);
console.error(`\nAdd each one to the \`external\` list in ${CONFIG}.`);
process.exit(1);
console.error(`\nAdd each one to the \`external\` list in ${config}.\n`);
return false;
}
// Deliberately no complaint about declarations nothing imports. Some of them
// cannot be seen from the source at all — `react/jsx-runtime` is injected by
// the JSX transform — and an extra declaration is inert, so there is nothing
// to warn about.
console.log(
`${declared.size} external declarations cover all ${seen.size} imports of ${MUST_BE_EXTERNAL.join(", ")}.`,
);
let ok = true;
for (const target of TARGETS) ok = (await check(target)) && ok;
if (!ok) process.exit(1);
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Clones the branch of a dependency's repository that has the same name as
# the pull request branch under test, so a change spanning both repositories
# can be tested before either side is merged. Inspired by scripts/fetchdep.sh
# element-web.
#
# Usage: scripts/fetchdep.sh <org> <repo> [ref]
#
# The clone lands in ./<repo>. When the pull request comes from a fork and
# PR_HEAD_REPO_OWNER was set, the fork's own copy of the repository is tried
# before <org>'s. Exits 0 when a branch was cloned and 1 when there is none,
# so the caller can keep the locked version in that case. Without a ref,
# nothing is closed outside a pull request.
set -eu
org=$1
repo=$2
ref=${3:-}
rm -rf "$repo"
# If a ref was passed, try to clone it or die.
if [ -n "$ref" ]; then
echo "fetchdep.sh: Using $org/$repo at $ref"
git clone --depth 1 "https://github.com/$org/$repo.git" "$repo"
git -C "$repo" fetch --depth 1 origin "$ref"
git -C "$repo" -c advice.detachedHead=false checkout FETCH_HEAD
git -C "$repo" log -1
exit 0
fi
# Otherwise ensure that we're on a pull request.
head_ref=${GITHUB_HEAD_REF:-}
if [ -z "$head_ref" ]; then
echo "fetchdep.sh: Not a pull request, nothing to match"
exit 1
fi
# Bots name their branches the same way in every repository, so a match on
# one of theirs would be a coincidence rather than a paired change.
case "$head_ref" in
renovate/* | dependabot/*)
echo "fetchdep.sh: Not matching bot branch $head_ref"
exit 1
;;
esac
# Clones a branch if it exists and ends the script on success.
clone() {
local try_org=$1
local branch=$2
echo "fetchdep.sh: Trying to use $try_org/$repo#$branch"
# Disable auth prompts: https://serverfault.com/a/665959
if GIT_TERMINAL_PROMPT=0 git clone "https://github.com/$try_org/$repo.git" "$repo" --branch "$branch" --depth 1; then
git -C "$repo" log -1
# Surface the match on the workflow run summary.
echo "::notice title=Matched $repo branch::Using $try_org/$repo#$branch at $(git -C "$repo" rev-parse HEAD)"
exit 0
fi
}
# Try cloning from the fork, then from the org.
head_owner=${PR_HEAD_REPO_OWNER:-}
if [ -n "$head_owner" ] && [ "$head_owner" != "${GITHUB_REPOSITORY_OWNER:-}" ]; then
clone "$head_owner" "$head_ref"
fi
clone "$org" "$head_ref"
# Nothing worked. Die.
echo "fetchdep.sh: No branch named $head_ref in $org/$repo"
exit 1
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Installs dependencies, then swaps matrix-js-sdk for a branch of the same
# name as the pull request branch under test if one exists. This is what
# lets CI test an Element Call branch together with a matrix-js-sdk branch
# before either is merged. Inspired by scripts/layered.sh in element-web.
#
# Usage: scripts/layered.sh [pnpm install flags]
#
# The layering is only performed when package.json uses the develop branch of
# matrix-js-sdk and the run is for a pull request whose branch has a matrix-js-sdk
# counterpart. Otherwise the dependency is left as pnpm-lock.yaml pins it.
#
# The matrix-js-sdk checkout is nested inside this directory because some CI
# systems do not allow moving above the primary checkout. It is git-ignored.
#
# Set JS_SDK_GITHUB_BASE_REF to a ref to use that exact ref of matrix-js-sdk
# instead of looking for a matching branch.
#
# This script is only meant to be used in CI. For local development use
# see docs/linking.md.
set -ex
# Paths below are relative to the repository root.
cd "$(dirname "$0")/.."
pnpm install --frozen-lockfile "$@"
js_sdk_dep=$(jq -r '.devDependencies["matrix-js-sdk"]' < package.json)
# Only layer when package.json follows the branch head rather than pinning a
# release, so a release build keeps its pinned version.
if [ "$js_sdk_dep" != "github:matrix-org/matrix-js-sdk#develop" ]; then
echo "layered.sh: Skipping matrix-js-sdk fetch and install as package.json pins $js_sdk_dep"
exit 0
fi
if ! scripts/fetchdep.sh matrix-org matrix-js-sdk "${JS_SDK_GITHUB_BASE_REF:-}"; then
echo "layered.sh: No matching matrix-js-sdk branch, keeping the locked version"
exit 0
fi
# matrix-js-sdk pins its pnpm version in `devEngines` only. Under corepack,
# pnpm does not switch versions on its own and corepack, finding no
# `packageManager` field there, would walk up to this project's and use
# ours. Pinning it in the clone makes every pnpm invocation inside that
# directory, including the `prepare` script run by pack, use the right one.
js_sdk_pnpm="pnpm@$(jq -r '.devEngines.packageManager.version' < matrix-js-sdk/package.json)"
jq --arg pm "$js_sdk_pnpm" '.packageManager = $pm' matrix-js-sdk/package.json > matrix-js-sdk/package.json.tmp
mv matrix-js-sdk/package.json.tmp matrix-js-sdk/package.json
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# Install matrix-js-sdk's build dependencies. `prepare` runs on pack below,
# so it is skipped here.
(cd matrix-js-sdk && pnpm install --frozen-lockfile --ignore-scripts)
# Rather than `pnpm link`, build matrix-js-sdk into a tarball and install
# that. This better reflects a clean install: dependencies may be hoisted
# and shared, and there is no tsconfig.json to pick up sources from.
(cd matrix-js-sdk && pnpm pack --out matrix-js-sdk.tgz)
pnpm install "$(pwd)/matrix-js-sdk/matrix-js-sdk.tgz" "$@"
@@ -0,0 +1,68 @@
# SDK mode (LEGACY)
> Superseded by the `sdk/` package (`@element-hq/matrixrtc-sdk`) and its harness in
> `sdk/dev`; see `sdk/SdkArchitecture.md`. This bundle is in production use, so it
> keeps building as a thin layer over that package until its consumers have moved
> to the package directly. It will then be removed. Its members are the package's
> `RTCMember`s now, in place of its own wrapper around the raw membership.
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
It allows to use matrixRTC in combination with livekit without relying on element call.
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver allowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
## Getting started
To get started run
```
pnpm install
pnpm build:sdk-target-based-on-call-view-model
```
in the repository root. (`pnpm build:sdk` builds the `sdk/` package instead.)
CI's `sdk` package (`build:sdk:production`, `build:sdk:development`, the
`build-output-sdk` artifact and the `pr<N>-sdk` Netlify preview) is still this
bundle, not the `sdk/` package, until its consumers have moved over.
It will create `dist` containing the compiled js file, `matrixrtc-sdk.js`.
This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a remote server.
Now you just need to add the widget to element web via:
```
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
## Widgets
The sdk mode is particularly interesting to be used in widgets. In widgets you do not need to pay attention to matrix login/cs api ...
To create a widget see the example `index.html` file in this folder. And add it to EW via:
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
### url parameters
The url parameters are needed to pass initial data to the widget. They will automatically be used
by the matrixRTCSdk to start the postmessage widget api (communication between the client (e.g. Element Web) and the widget)
```
widgetId = $matrix_widget_id
userId = $matrix_user_id
deviceId = $org.matrix.msc3819.matrix_device_id
baseUrl = $org.matrix.msc4039.matrix_base_url
```
`parentUrl = // will be inserted automatically`
Full template use as `<widgetUrl>`:
```
http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
@@ -30,12 +30,12 @@
div.appendChild(child);
});
sdk.members$.subscribe((memberObjects) => {
sdk.remoteMembers$.subscribe((memberObjects) => {
const div = document.getElementById("members");
div.innerHTML = "<h3>Members:</h3>";
// Create member list
const members = memberObjects.map((member) => member.membership.sender);
const members = memberObjects.map((member) => member.userId);
console.info("members changed", members);
for (const m of members) {
console.info("member", m);
+191
View File
@@ -0,0 +1,191 @@
/*
Copyright 2025-2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* LEGACY, in production use
*
* The entrypoint of `pnpm build:sdk-target-based-on-call-view-model`, a
* bundle for widgets that exposes `createMatrixRTCSdk`: join a slot and
* exchange realtime data, with the `@element-hq/matrixrtc-sdk` package and
* Element Call's call view model doing the work underneath. New consumers use
* the package directly; this bundle goes away once the existing ones have.
*/
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { EMPTY, filter, map, type Observable, of, switchMap, take } from "rxjs";
import {
type Behavior,
createRTCSlot,
E2eeType,
type LocalRTCMember,
ObservableScope,
type RemoteRTCMember,
type RTCParticipation,
} from "@element-hq/matrixrtc-sdk";
import {
callViewModelOptionsFromParams,
type CallViewModel,
createCallViewModel$,
initialPublishRequests,
} from "../src/state/CallViewModel/CallViewModel";
import {
captureSettings,
rtcParticipationOptions,
rtcSlotOptions,
} from "../src/room/InCallView";
import { createWidgetHostBridge } from "../src/HostBridge";
import { MediaDevices } from "../src/state/MediaDevices";
import { MuteStates } from "../src/state/MuteStates";
import { getUrlParams } from "../src/UrlParams";
import { observeElementSize$ } from "../src/utils/elementSize";
import { initializeWidget } from "../src/widget";
/** The data channel topic this bundle has always used, so that old and new builds talk. */
const TOPIC = "matrixRTC";
export interface MatrixRTCSdk {
/** Joins the slot; `connected$` tracks the progress. A second call does nothing. */
join: () => void;
leave: () => void;
/** Ends everything. The sdk cannot be restarted; create a new one. */
stop: () => void;
connected$: Behavior<boolean>;
/** Our own member, null until our membership has been seen in the room. */
localMember$: Behavior<LocalRTCMember | null>;
/** The other members of the slot, with the media the participation carries for them. */
remoteMembers$: Behavior<RemoteRTCMember[]>;
/** What other members sent with `sendData`. */
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
/** Sends to every member on our transport. Rejects while not joined. */
sendData: (data: unknown) => Promise<void>;
sendRoomMessage: (message: string) => Promise<void>;
}
export async function createMatrixRTCSdk(
application = "m.call",
id = "",
sticky = false,
): Promise<MatrixRTCSdk> {
const logger = rootLogger.getChild("[MatrixRTCSdk]");
const scope = new ObservableScope();
const widget = initializeWidget(application, true);
if (!widget) throw new Error("No widget: this bundle only runs as a widget");
const client = await widget.client;
const hostBridge = createWidgetHostBridge(widget);
const urlParams = getUrlParams();
const { roomId, controlledAudioDevices, callIntent } = urlParams;
const room = roomId === null ? null : client.getRoom(roomId);
if (room === null) throw new Error("No room: the url needs a joined roomId");
const mediaDevices = new MediaDevices(scope, {
controlledAudioDevices,
callIntent,
});
const muteStates = new MuteStates(
scope,
mediaDevices,
{ audioEnabled: false, videoEnabled: false },
hostBridge,
);
const encryptionSystem = { kind: E2eeType.PER_PARTICIPANT } as const;
const capture = captureSettings();
const slot = createRTCSlot(scope, client, room, {
...rtcSlotOptions(encryptionSystem),
application,
id,
});
let vm: CallViewModel | undefined;
const failed =
(what: string) =>
(e: unknown): void =>
logger.error(what, e);
const over = <T>(
pick: (participation: RTCParticipation) => Observable<T>,
fallback: T,
): Behavior<T> =>
scope.behavior(
slot.participation$.pipe(
switchMap((p) => (p === null ? of(fallback) : pick(p))),
),
);
return {
join: (): void => {
if (slot.participation$.value !== null) return;
if (sticky)
widget.api
.setAlwaysOnScreen(true)
.catch(failed("Failed to make the widget sticky"));
const participation = slot.join({
...rtcParticipationOptions(urlParams),
publish: initialPublishRequests(muteStates, mediaDevices, capture),
});
vm = createCallViewModel$(
scope,
participation,
room,
mediaDevices,
muteStates,
{
...callViewModelOptionsFromParams(urlParams),
encryptionSystem,
capture,
hostBridge,
// The bundle owns its page, so the body is the space it has
windowSize$: scope.behavior(observeElementSize$(document.body)),
},
of({}),
of({}),
);
// Once the hang-up has gone through, unstick and ask the host to close us
vm.leave$
.pipe(
switchMap(() =>
participation.state$.pipe(
filter((state) => state.kind === "left"),
take(1),
),
),
scope.bind(),
)
.subscribe(() => {
hostBridge
.setAlwaysOnScreen(false)
.catch(failed("Failed to unstick the widget"))
.then(() => hostBridge.close?.())
.catch(failed("Failed to ask the host to close"));
});
},
leave: (): void => vm?.leave(),
stop: (): void => scope.end(),
connected$: over(
(p) => p.state$.pipe(map((state) => state.kind === "connected")),
false,
),
localMember$: over((p) => p.localMember$, null),
remoteMembers$: over((p) => p.remoteMembers$, []),
// Attested members only: the SDK has matched the sender
data$: slot.participation$.pipe(
switchMap((p) => p?.data$ ?? EMPTY),
filter(({ topic }) => topic === TOPIC),
map(({ member, text }) => ({
rtcBackendIdentity: member.rtcBackendIdentity,
data: text,
})),
),
sendData: async (data): Promise<void> => {
const participation = slot.participation$.value;
if (participation === null) throw new Error("Not joined");
await participation.sendData(TOPIC, JSON.stringify(data));
},
sendRoomMessage: async (message): Promise<void> => {
await client.sendTextMessage(room.roomId, message);
},
};
}
+94 -41
View File
@@ -1,59 +1,112 @@
# SDK mode (EXPERIMENTAL)
# MatrixRTC SDK (EXPERIMENTAL)
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
`@element-hq/matrixrtc-sdk` is the call model under Element Call, on its own:
it joins a MatrixRTC slot in a Matrix room, writes and watches the memberships,
connects to the media transport (LiveKit today), publishes the local media and
exposes every member and their tracks as observables. It has no UI. Element
Call's own `CallViewModel` is one consumer of it; any host can build a different
view on top. The design is in [`SdkArchitecture.md`](./SdkArchitecture.md), what
moved out of Element Call's `src/` in [`SdkMigration.md`](./SdkMigration.md).
It allows to use matrixRTC in combination with livekit without relying on element call.
**Status:** first implementation. `createRTCSlot` and `slot.join()` join the
session, connect to the transport, publish the local media and expose every
member's media; the development harness and its e2e tests in `playwright/sdk`
drive it, and Element Call's own `CallViewModel` is built on it. It depends on
nothing in Element Call's `src/`.
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
## Using it
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver allowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
```ts
import {
createRTCSlot,
E2eeType,
MatrixRTCMode,
ObservableScope,
trackBySource$,
} from "@element-hq/matrixrtc-sdk";
## Getting started
const scope = new ObservableScope();
const slot = createRTCSlot(
scope,
client, // a matrix-js-sdk MatrixClient, logged in and syncing
room, // the matrix-js-sdk Room, from client.getRoom() once the join has synced
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
matrixRTCMode: MatrixRTCMode.Compatibility,
},
);
const participation = slot.join({
publish: [{ source: "microphone" }, { source: "camera" }],
});
To get started run
// A <video> for each remote member's camera and an <audio> for its microphone.
// A track plays on an element once attached to it; detach before the element
// leaves the DOM. (`tileFor`, `setName` and `removeTilesExcept` are the host's.)
participation.remoteMembers$.subscribe((members) => {
for (const member of members) {
const { video, audio } = tileFor(member.memberId);
member.displayName$.subscribe((name) => setName(member.memberId, name));
trackBySource$(scope, member.tracks$, "camera").subscribe((track) =>
track?.attach(video),
);
trackBySource$(scope, member.tracks$, "microphone").subscribe((track) =>
track?.attach(audio),
);
}
removeTilesExcept(members.map((m) => m.memberId));
});
```
pnpm install
pnpm build:sdk
// later
participation.leave();
scope.end();
```
in the repository root.
`participation.localMember$` is one member of the same shape, null until our own
membership is in the room; its microphone would only echo.
It will create a `dist` folder containing the compiled js file.
[`dev/main.ts`](./dev/main.ts) is this example as a working page.
This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a remote server.
## Reading the code
Now you just need to add the widget to element web via:
Three files define the surface. Read them in this order.
```
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
- [`src/api.ts`](./src/api.ts): the MatrixRTC side. An `RTCSlot` is the
namespace in a room that memberships join into; `join()` returns an
`RTCParticipation`, which lasts until its `leave()` and exposes `state$`,
`localMember$` and `remoteMembers$`. Members, transports and the participation
state machine live here. `createRTCSlot` in `RTCSlot.ts` is the only way to get
an implementation.
- [`src/media-api.ts`](./src/media-api.ts): media as a host sees it, on
`remoteMembers$` and `localMember$`. `PublishRequest` says what to publish
(microphone, camera, screen share); `MediaTrack` is how it arrives. The
underlying tracks stay hidden: the host hands an element to `attach` and the
SDK plays into it. The local variants carry the controls (`setEnabled`,
`setDevice`, `setProcessor`).
- [`src/media-backend/api.ts`](./src/media-backend/api.ts): internal, the seam
between the session and whatever carries the media. The session speaks
MatrixRTC; a `MediaBackend` speaks one media protocol and hands back the
`MemberMedia` of each member through `mediaFor()`. A host rarely touches it;
it matters when working on the LiveKit backend under
`src/media-backend/livekit/`, the only place that imports `livekit-client`, or
when writing a new backend.
## Development
Everything runs from the repository root. This directory is a pnpm project of its
own only so that a host can install it as a git dependency (see
`pnpm-workspace.yaml`).
```sh
pnpm dev:sdk # the harness, https://localhost:3002
pnpm build:sdk # dist/matrixrtc-sdk.js and dist/types
pnpm test:playwright playwright/sdk # needs `pnpm backend`
```
## Widgets
## Installing
The sdk mode is particularly interesting to be used in widgets. In widgets you do not need to pay attention to matrix login/cs api ...
To create a widget see the example `index.html` file in this folder. And add it to EW via:
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
### url parameters
The url parameters are needed to pass initial data to the widget. They will automatically be used
by the matrixRTCSdk to start the postmessage widget api (communication between the client (e.g. Element Web) and the widget)
```
widgetId = $matrix_widget_id
perParticipantE2EE = true
userId = $matrix_user_id
deviceId = $org.matrix.msc3819.matrix_device_id
baseUrl = $org.matrix.msc4039.matrix_base_url
```sh
pnpm add github:element-hq/element-call#<ref>&path:/sdk
```
`parentUrl = // will be inserted automatically`
Full template use as `<widgetUrl>`:
```
http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
The host supplies `matrix-js-sdk`, `livekit-client` and `rxjs`; the build leaves
them external.
+829
View File
@@ -0,0 +1,829 @@
# MatrixRTC SDK architecture
`@element-hq/matrixrtc-sdk` is the model under Element Call, on its own: one
`MatrixRTCClient` per room that joins and leaves the MatrixRTC session, discovers
and connects to the media transports, publishes the local media, distributes the
encryption keys, and hands out every member with its media as observables. It has
no UI, reads no configuration and no settings, and never names LiveKit in its
interface.
Element Call becomes one consumer of it: `CallViewModel` keeps what makes a session
a _call_ (ringing, auto-leave, layout, sounds, reactions, settings, the host
bridge) and takes everything else from the client. The dependency direction is
therefore `Element Call → sdk`, never the reverse. What moved out of Element
Call's `src/` to get there is listed in [`SdkMigration.md`](./SdkMigration.md).
## The architecture
```
host application (Element Call, a whiteboard, watch-together, …)
│
│ createMatrixRTCClient(scope, matrixClient, room, options)
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ MatrixRTCClient │
│ │
│ public API, all Behaviors: │
│ join() leave() state$ │
│ localMember$ remoteMembers$ memberCount$ sendData() data$ │
│ keyRotationSuppressed$ connectedTransports$ │
│ │
│ per member RTCMember { id userId deviceId membership$ displayName$ │
│ avatarUrl$ transport$ tracks$ encryptionError$ }│
│ per track MediaTrack { source kind muted$ encrypted stats$ attach() } │
│ │
│ ┌───────────────────────────┐ MediaBackend ┌───────────────────────────┐ │
│ │ session: MatrixRTC │ media-backend/ │ media backend │ │
│ │ │ api.ts │ media-backend/livekit/ │ │
│ │ js-sdk MatrixRTCSession, │ │ today, or options.backend │ │
│ │ later the rust-rtc │ prepareLocal │ │ │
│ │ crate behind a bridge │ Transport ───▶│ auth: MSC4195 CS API or │ │
│ │ memberships from room │ delegateDelayed │ JWT service, by shape │ │
│ │ state and sticky events │ Leave ───────▶│ and role; delegation │ │
│ │ own membership, delayed │ │ ConnectionMap: one │ │
│ │ leave, retries │ mediaKeys$ ────▶│ LiveKit room per │ │
│ │ discovery of the local │ mediaFor$ ─────▶│ transport locator, │ │
│ │ transport (homeserver) │ ◀─ tracks$, │ local published, │ │
│ │ membership state machine │ encryptionError$│ remote subscribed │ │
│ │ (LocalMembership) │ local.publish, │ Publisher: local tracks, │ │
│ │ transport registry │ setPublishing▶│ upstream paused until │ │
│ │ (TransportMetadata) │ ◀─ connections$ │ joined │ │
│ │ data packet → member │ ◀─ data$ │ LivekitMemberMedia: │ │
│ │ │ │ participant → tracks │ │
│ └───────────────────────────┘ └───────────────────────────┘ │
│ │
│ RTCMember = membership + mediaFor$(membership): the backend matches the │
│ member's backendIdentities to the identity it sees on the transport │
└──────────────────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
MatrixClient (sync, room state, to-device, OpenID) LiveKit SFU
```
The left box is MatrixRTC: who is in the session, with which transport, the
keys, the delayed leave, and the membership state machine. The right box is a
media backend: whatever carries the media, behind the `MediaBackend` interface in
`sdk/src/media-backend/api.ts`. The client is the join of the two: a member exists
once its membership does, and gets its media once the backend has something
for that membership on its transport; `state$` is the membership state combined
with the local media connection. LiveKit is the one backend today; a
cascading SFU or full mesh is another folder under `sdk/src/media-backend/`, handed
in through `options.backend`, and nothing in the left box changes.
### How Element Call uses it
```
Element Call, as app, widget or component
┌────────────────────────────────────────────────────────────────┐
│ views: InCallView, tiles, header, footer, settings │
│ ▲ Behaviors │
│ CallViewModel │
│ layout, ringing and notifications, auto-leave, sounds, │
│ reactions, hand raise, settings, audio routing, host bridge │
│ ▲ state$, localMember$, remoteMembers$, tracks$ │
│ MatrixRTCClient @element-hq/matrixrtc-sdk │
└────────────────────────────────────────────────────────────────┘
│ MatrixClient + Room │ livekit-client
▼ ▼
homeserver SFU
```
All three modes already hold a `MatrixClient` and a `Room` by the time a call
starts: standalone after `useLoadGroupCall`, the component from the host's `client`
prop, the widget from `createRoomWidgetClient`. The view model passes them on and
builds its media view models from `RTCMember` and its `tracks$` instead of from
LiveKit participants.
### Joining, in order
```
host MatrixRTCClient / backend homeserver / JWT service LiveKit SFU
│ create ───▶│ │ │
│ │ GET rtc/transports (session) ────────▶│ │
│ │◀── first transport of the backend's type │
│ │ (none, LiveKit only: a member's SFU │ │
│ │ that lets us publish, get_token) ─▶│ │
│ │ prepareLocalTransport (backend): │ │
│ │ publisher token: CS API get_token, │ │
│ │ or OpenID token then /sfu/get ─────▶│ │
│ │◀── sfu url, jwt, alias, identity ─────│ = resolved$ of the │
│ │ probe delegate_delayed_leave ──────▶│ local transport │
│ │ connect(url, jwt) ───────────────────────────────────────────▶│
│ join() ───▶│ (once the publisher holds its token)│ │
│ │ membership state event + delayed leave ──▶│ │
│ │ delegateDelayedLeave(delayId) (backend) ─▶│ │
│ │ create tracks, resume upstream ──────────────────────────────▶│
│ │ media key to every other member (to-device) ──▶│ │
│ │◀── other memberships (sync) ──────────│◀── participants ────────│
│◀─ state$ connected, localMember$, remoteMembers$ with tracks$ ───────────────│
```
The session discovers the transport from the homeserver and the backend prepares
it, both before `join()`, so that the membership can name it and the first frames
go out as soon as the state event is sent. Preparing is the backend's own
business: for LiveKit the token exchange for the publisher connection, and the
probe that decides whether the service that issued the token can take over the
delayed leave, which picks the delayed leave timings the join uses. The join
waits for the publisher to hold its token, so that the LiveKit room exists
before a subscriber, who lacks the permission to create it, asks for a token
of its own. Until `join()` the tracks exist but their upstream is paused: a host
can show a preview without anyone hearing it. The upstream flows exactly while the
membership is `joined`: a sync outage pauses it, and `leave()` pauses it for good
and sends the leave.
## Vocabulary
The words the SDK is written in. They name the shape after the slot and
participation split in
[`client-into-slot-session-split-plan.md`](./client-into-slot-session-split-plan.md);
the sections below still describe `MatrixRTCClient` until that lands.
| Word | Meaning here | Elsewhere |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| slot | an `RTCSlot`: the namespace in a room that memberships join into, `application` plus `id`. It exists while the room does, with nobody in it too. Holds `members$`, its open or closed `status$`, and `join()` | the js-sdk `MatrixRTCSession` is one per slot; the MSC4143 slot event |
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `state$`, `localMember$`, `remoteMembers$`, the connected transports and the data channel. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot |
| member | an `RTCMember`: one membership in a slot with what the SDK derives from it, `rtcBackendIdentity`, `userId`, `deviceId`, `memberId`, `displayName$`, `avatarUrl$`, `transport$` and `applicationData$`. No media. What the slot hands out in `members$` | the js-sdk `CallMembership` behind it, which never leaves the SDK; Element Call's old "matrixLivekitMember" |
| local member, remote member | `LocalRTCMember` and `RemoteRTCMember`: an `RTCMember` plus its `MemberMedia`, which only a participation can carry. The local one's tracks carry the controls. What the participation hands out in `localMember$` and `remoteMembers$` | LiveKit `LocalParticipant` and `RemoteParticipant`, which never leave the SDK |
| media | a `MemberMedia`: what a member sends once it has arrived on its transport, `tracks$`, null while waiting, and `encryptionError$` beside it. The media backend supplies exactly these two fields per member | Element Call's `MediaViewModel` is a view of it |
| track | a `MediaTrack`: one publication of a member, audio or video, with `source`, `kind`, `id` and the behaviors on it. `LocalAudioMediaTrack` and `LocalVideoMediaTrack` add the controls over our own | LiveKit `TrackPublication` and `Track`, which never leave the SDK |
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
`RTCMember`, `matrixRTCMode`.
## Reactive primitives
The whole public API is made of `Behavior`s: an rxjs observable that always holds a
current value (`.value`) and replays it on subscription. A consumer subscribes to
what it renders and reads `.value` where it needs the present state. Everything is
bound to an `ObservableScope` the host creates; ending the scope ends the client,
its connections and its tracks. Both come from the SDK (`Behavior`, `constant`,
`ObservableScope`); the host needs rxjs and nothing else.
## What a host must provide
Found the hard way while building the harness; each one fails in a way that looks
like an SDK bug.
- **The room the sync loop maintains.** `client.getRoom(roomId)` once the join has
synced. `joinRoom` returns a detached copy for a room joined just now, and a
client built on it never sees a member.
- **Power to send the membership event.** Every member needs to write
`org.matrix.msc3401.call.member` (and the sticky event in Matrix 2.0 mode). A
plain room only lets its creator send state; Element Call's rooms set the level
to 0.
- **An encrypted room for per-participant keys.** Media keys travel in encrypted
to-device messages, which the crypto only delivers to devices it tracks, which
are the members of encrypted rooms. In an unencrypted room the key never arrives
and every remote track decodes nothing.
- **A logged-in, syncing client with crypto set up**, and the transport advertised
by the homeserver (`rtc/transports`). The SDK has no fallback url of its own.
## The client: `MatrixRTCClient`
Rule of thumb for what belongs here: if it would be the same for a different
application on top of the same MatrixRTC session, it is in the client. If it is
specific to calls, or depends on the window, a setting, a platform, or a React
context, it is in the view model.
### Creating one
```ts
import { type MatrixClient, type Room } from "matrix-js-sdk";
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
export interface MatrixRTCClientOptions {
encryptionSystem: EncryptionSystem;
/** Resolved by the host; the SDK reads neither config.json nor settings. */
matrixRTCMode: MatrixRTCMode;
/** Published at the join; `publish` on the local member adds to it from then on. */
publish: PublishRequest[];
/**
* MSC4075 notification sent with the join. A parameter of the MatrixRTC
* join itself, so it is here even though it is named after calls; reacting
* to a notification (ringing, timeouts, declines) is the application's job.
*/
sendNotificationType?: RTCNotificationType;
/** The application the session belongs to, as named in the membership. Default `m.call`. */
application?: string;
/** The application's slot in the room. Default `ROOM`. */
slot?: string;
/**
* Whatever the application wants to say about itself in the membership.
* Opaque to the SDK. Today the js-sdk carries one key, `m.call.intent`
* (`"audio"` or `"video"`); anything else is dropped until it can.
*/
applicationData?: Record<string, unknown>;
/** Session timings the host has configured; the defaults otherwise. */
timings?: Partial<SessionTimings>;
/** Limits on what is published; LiveKit's defaults otherwise. */
mediaQuality?: MediaQuality;
/** Use this transport instead of asking the homeserver. */
transport?: Transport;
/** Use this transport when the homeserver advertises none of the backend's type. */
fallbackTransport?: Transport;
}
/**
* One thing to publish: a source, where to capture it from and how to encode
* it. Where the device and the settings are left out, the browser's and
* LiveKit's defaults apply.
*/
export type PublishRequest =
| { source: "microphone"; deviceId?: string; capture?: AudioCaptureSettings }
| {
source: "camera";
deviceId?: string;
/** Background blur and the like. */
processor?: VideoProcessor;
capture?: VideoCaptureSettings;
}
| {
source: "screenShare";
/** Whether to capture the screen's audio too. Default true. */
audio?: boolean;
capture?: VideoCaptureSettings;
};
```
Device enumeration, permission prompts and the lobby preview stay with the
host: it hands over publish requests naming the devices it chose.
```ts
/**
* Takes the whole MatrixClient, not a narrow pick, and finds the MatrixRTC
* session itself. The js-sdk is the MatrixRTC implementation today; when the
* rust-rtc crate replaces it, the SDK has to bridge the crate to the js-sdk
* client (events, state, to-device, delayed events, OpenID), and only the SDK
* knows what that bridge needs. Holding the client keeps that change inside
* the SDK. The js-sdk session is not part of the public API.
*
* The room has to be the one the client's sync loop maintains; see "What a
* host must provide".
*/
export function createMatrixRTCClient(
scope: ObservableScope,
client: MatrixClient,
room: Room,
options: MatrixRTCClientOptions,
): MatrixRTCClient;
```
### The client
```ts
/** The homeserver link that is down, in the order they depend on each other. */
export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
/** Why the participation is not connected: the first failing link, in dependency order. */
export type DisconnectReason = MatrixDisconnectReason | "media";
export type ParticipationState =
| { kind: "waitingForTransport" }
| { kind: "connecting" }
| { kind: "connected" }
| { kind: "reconnecting"; reason: DisconnectReason }
/** A failed publication is not fatal: the member can still receive, so it stays on the local member's media. */
| { kind: "failed"; error: MatrixRTCError }
/** `leave()` was called. Terminal. */
| { kind: "left" };
/**
* An error raised by the client. `code` and `category` say what went wrong in
* a form a host can translate; `cause` holds the backend's error.
*/
export class MatrixRTCError extends Error {
code: ErrorCode;
category: ErrorCategory;
}
export interface MatrixRTCClient {
join(): void;
leave(): void;
/**
* The Matrix membership combined with the local media connection. One
* value: a reason exists only while `reconnecting`, an error only once
* `failed`.
*/
state$: Behavior<ParticipationState>;
/** Null until our own membership has been seen in the room. */
localMember$: Behavior<LocalRTCMember | null>;
remoteMembers$: Behavior<RemoteRTCMember[]>;
/** `remoteMembers.length`, plus one for the local member once it exists. One per membership, so one user may count several times. */
memberCount$: Behavior<number>;
/**
* Whether the session has grown large enough that MatrixRTC has stopped
* rotating the media encryption key.
*/
keyRotationSuppressed$: Behavior<boolean>;
/**
* Transports the client currently holds a live connection to. The
* connections themselves stay internal; this is what a developer panel
* needs.
*/
connectedTransports$: Behavior<TransportMetadata[]>;
/**
* Plays the members' audio on this device from now on. Rejects where the
* browser cannot switch to it. A host that routes audio itself, or leaves
* the browser's choice, never calls it.
*/
setAudioOutputDeviceId(deviceId: string): Promise<void>;
/**
* Sends a short text to every member on the local transport, over a
* reliable data channel beside the media. A packet holds about 15 KiB;
* anything larger belongs in a room event. Rejects while the local
* transport is not connected. Encrypted on the wire like the media, but
* not end to end with the media key.
*/
sendData(topic: string, text: string): Promise<void>;
/**
* What remote members sent with `sendData`, on every transport the client
* is connected to. A message from an identity that is not a member is
* dropped, so a host only ever hears from attested members.
*/
data$: Observable<DataMessage>;
}
/** One `sendData` call as it arrives at a remote member. */
export interface DataMessage {
member: RemoteRTCMember;
topic: string;
text: string;
}
```
`state$` is two state machines, one per box in the diagram, combined by one pure
function. `LocalMembership` knows only the Matrix side: `waitingForTransport`,
`joining`, `joined`, `reconnecting` with a `MatrixDisconnectReason`, `failed`,
`left`. The client folds the local media connection in: `joined` with the media
up is `connected`, a media drop after that is `reconnecting: "media"`, a
connection error is `failed`. Each step is computed in priority order from the
previous state, so `left` and `failed` absorb everything after them, and
`reconnecting` is only ever reached from `connected`; a membership that drops
before the media ever came up stays `connecting`. One reason, not a set, because
the links are a chain: without sync the membership is unknowable, and the media
is worth little while the Matrix side is down, so the first broken link in that
order is what a host shows and what Element Call's analytics count. A failed
publication is not in here at all: the member can still receive, so it stays on
the local member's media.
### Members
```ts
/**
* What a membership says, as hosts read it. The js-sdk `CallMembership` is
* behind it; anything beyond this is the js-sdk's API, not the SDK's.
*/
export type RTCMembership = Pick<
CallMembership,
| "userId"
| "deviceId"
| "memberId"
| "rtcBackendIdentity"
| "application"
| "applicationData"
| "getTransport"
| "transports"
| "createdTs"
| "getAbsoluteExpiry"
>;
export interface RTCMember {
local: boolean;
/** The membership's `rtcBackendIdentity`, `${userId}:${deviceId}`: a key and a debug label. */
rtcBackendIdentity: string;
/**
* Every identity the member may publish under on its transport, one per
* authentication flow the transport allows: the hashed identity the
* MSC4195 client-server API assigns, the legacy one the JWT service's
* `/sfu/get` assigns, or both for a transport carrying both a `url` and a
* `livekit_service_url`. The backend matches whichever shows up.
*/
backendIdentities: readonly string[];
userId: string;
deviceId: string;
membership$: Behavior<RTCMembership>;
/**
* Matrix room state rather than MatrixRTC, but the SDK already holds the
* room and every consumer wants them next to the membership. Disambiguated
* among the members of the session.
*/
displayName$: Behavior<string>;
avatarUrl$: Behavior<string | undefined>;
/** Which transport this member is on; undefined when the membership names none. */
transport$: Behavior<TransportMetadata | undefined>;
/**
* The member's tracks, in publication order, once it has shown up on its
* transport; null until then ("waiting for media"). An entry stays the same
* object for as long as the same publication is behind it. Which track is
* which is in its `source`; `trackBySource$` picks one out. Hand raise and
* reactions are room events keyed by member and stay with the host for now;
* they are the obvious next fields here.
*/
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
/** Emits when the SFU reports a key problem for this member. */
encryptionError$: Observable<EncryptionError>;
}
export interface RemoteRTCMember extends RTCMember {
local: false;
}
export interface LocalRTCMember extends RTCMember {
local: true;
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
/**
* Publishes a source and resolves with its track once it is in `tracks$`.
* Before the transport is connected the request is remembered and applied
* once it is. Rejects where the device could not be used, including the
* user closing the picker. One publication per source: publishing a source
* again unmutes it.
*/
publish(
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
/** Removes one of our tracks; a screen share takes its audio with it. */
unpublish(id: string): Promise<void>;
}
```
### Transports
```ts
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
/**
* One transport advertised in a membership. Transport independent: `type` and
* `id` are all the SDK needs; `raw` and `resolved$` are there for a
* backend-specific developer panel and for connection diagnostics. One object
* per transport, however many memberships name it.
*/
export interface TransportMetadata {
/** `"livekit"` today. */
type: string;
/** Stable key, unique per transport in the session: the raw transport, serialised with sorted keys. */
id: string;
/** The transport object as it appears in the membership, e.g. `{ type, livekit_service_url }`. */
raw: Transport;
/**
* What the backend had to fetch before it could connect. Undefined until
* the connection has resolved it, and again after the connection stops or
* fails. The local transport resolves before the join, since the membership
* needs it; remote ones when their connection starts.
*/
resolved$: Behavior<ResolvedTransport | undefined>;
}
export type ResolvedTransport =
| {
type: "livekit";
/** The SFU websocket url, as opposed to the JWT service url in `raw`. */
url: string;
/** A secret: fit for a developer panel, never for a log line. */
token: string;
roomAlias: string;
identity: string;
}
| { type: string; [key: string]: unknown };
```
### Call-specific things kept out of the client
Checked against `CallViewModel` and its options. These stay in Element Call because
they only make sense for a call:
| Today | Why it is call-specific | Where it lives instead |
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `autoLeave$` / `AutoLeaveReason` (`"allOthersLeft" \| "timeout" \| "decline"`) | hanging up because the other side declined or everyone left is call etiquette, not session semantics | `CallViewModel.leave$`, built from `remoteMembers$` and the room timeline |
| `ringAttempts$`, `createCallNotificationLifecycle$`, `createSentCallNotification$`, `createReceivedDecline$` | ringing, pickup timeouts and decline events are MSC4075 call-notify UX | `src/state/CallViewModel/CallNotificationLifecycle.ts`, fed by `remoteMembers$` and the Matrix room |
| `waitForCallPickup`, `autoLeaveWhenOthersLeft` options | inputs to the above | `CallViewModelOptions` |
| `hideScreensharing` option | a UI policy; the session can always share a screen if the platform can | `CallViewModel` offers no screen share control |
| `ringingVm$`, `ringingStatusLocation` | view models | `CallViewModel` |
| `hostBridge.hangUp$`, `hangup()`, `userHangup$` | widget / host integration | `CallViewModel` |
| `ElementCallError` | carries Element Call translation keys | the view model wraps `MatrixRTCError`, reading `cause` |
| `participantCount$` sound limits, join/leave sounds | call UX | `CallViewModel` |
| `callIntent` | what a membership says about the call | `applicationData["m.call.intent"]`, set by the host |
Kept despite the name: `sendNotificationType`, because the js-sdk join sends the
notification as part of entering the session. Only _reacting_ to it is
call-specific.
## Media backend: `MediaBackend`
The client speaks MatrixRTC: memberships, the transports named in them, the
delayed leave, the keys. What carries the media is a backend behind one
interface, `MediaBackend` in `sdk/src/media-backend/api.ts`, created once per client.
LiveKit under `sdk/src/media-backend/livekit/` is the only backend today, and the only
place in the SDK that imports `livekit-client`; the import boundary lint rule
enforces that outside `sdk/src/media-backend/` the LiveKit packages appear in type
imports only.
The seam sits above the connection manager on purpose. One connection per
transport url, with a subscribe-only connection to every remote SFU, is the
LiveKit multi-SFU strategy, not a MatrixRTC concept: a cascading SFU keeps the
membership transport as it is and only changes which SFUs the client connects to,
and full mesh has one peer connection per member and no token service. So a
backend is handed the whole session's view and gives back media:
```ts
/** The fields of a member the backend supplies, as the member exposes them. */
type MemberMediaFields = Pick<RTCMember, "tracks$" | "encryptionError$">;
export interface MediaBackend {
readonly transportType: string; // "livekit"
/** Authenticate with the transport the client found; the probe for delegation runs here. */
prepareLocalTransport(
transport: Transport,
): Promise<{ canDelegateDelayedLeave: boolean }>;
/** Hand over the delayed leave, each time the membership manager has a new delay id. */
delegateDelayedLeave(delayId: string): Promise<void>;
readonly local: LocalMediaBackend; // tracks$ encryptionError$ connectionState$ setPublishing publishError$ publish unpublish
/** A remote member's media; also how the backend learns which transports to connect to. */
mediaFor$(
scope: ObservableScope,
membership$: Behavior<RTCMembership>,
): MemberMediaFields;
readonly connections$: Behavior<BackendConnection[]>; // transport, state, resolved; diagnostics
sendData(topic: string, text: string): Promise<void>;
readonly data$: Observable<DataPacket>;
}
```
What stays on the client's side: discovery of the local transport from the
homeserver's list (a homeserver endpoint, so not a backend's) with, for the
LiveKit backend only, a last resort of another member's SFU that lets us
publish (`findRemotePublishLivekitTransport`, wired in by `RTCParticipation` rather
than by the backend interface, since it is an MSC4195 matter), the membership
state machine in `LocalMembership.ts` and its combination with
`local.connectionState$` into `state$`, the choice of delayed leave timings,
mapping a data packet's sender to a member, and the transport registry, which keys on the raw
transport serialised with sorted keys and derives `connectedTransports$` and each
`TransportMetadata.resolved$` from `connections$`.
The backend's context carries no Matrix client and no membership list. A backend
that needs the client takes a `Pick` of exactly what it calls in its own options
(`createLivekitBackend(scope, context, { client, mediaQuality })`),
and learns of remote members only through `mediaFor$`, registering each
membership's transport for as long as the member's scope lives. Media keys reach
it as a stream, `mediaKeys$`, replayed on subscribe; the js-sdk session never
does. A host supplies its own backend through `MatrixRTCClientOptions.backend`;
without one, LiveKit is built from the LiveKit-flavoured client options.
### Authenticating with a LiveKit transport
A LiveKit transport comes in three shapes while MSC4195 settles: an SFU `url`,
a JWT service `livekit_service_url`, or both. `media-backend/livekit/auth/`
fronts the two flows and chooses between them per connection, from the shape
and from the role we take on the transport:
| Transport | Publisher | Subscriber |
| -------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `url` only | client-server API `get_token`, no fallback | client-server API `get_token`, no fallback |
| `livekit_service_url` only | JWT service `/sfu/get` (legacy identity) | JWT service `/get_token` (hashed identity) |
| both | JWT service `/sfu/get` (legacy identity) | client-server API, falling back to the JWT service `/get_token` where the homeserver, or a widget host, has no MSC4195 |
A publisher on a transport with a service URL keeps the legacy
`<user_id>:<device_id>` identity, because clients predating MSC4195 match a
membership against that and nothing else; a subscriber is matched by nobody and
takes the more private hashed identity. A publisher never falls back: a
homeserver that advertises a `url` without implementing MSC4195 is misconfigured
and fails with `NoMatrix2AuthorizationService`, so the admin hears about it.
The MatrixRTC mode plays no part in the choice. The delayed leave is delegated
through the same flow that issued the publisher's token, so that the identity
the service watches for on the SFU is the one we publish under, and the
delegation probe before the join asks only that service.
The client-server API needs the name of the homeserver the transport belongs to:
our own homeserver asks that server for the token, over federation where it is
not ours. The backend derives it from the member's user id, and from our own for
the local transport, and keys its connections on the transport plus the server
name where the transport has a `url`. A JWT service is reached directly, so
members of several homeservers advertising the same one share a connection.
## Media: `tracks$` and `MediaTrack`
Every member has media. Today, in Element Call, that media _is_ a `livekit-client`
participant, handed straight through to the view models. In the SDK the participant
becomes the member's `tracks$`, a list of `MediaTrack`s, so that nothing outside
the SDK imports `Participant`, `TrackPublication`, `Track` or `TrackReference`.
The adapter in `sdk/src/media-backend/livekit/` is the only place that reads a
participant. The backend supplies the same two fields through `mediaFor$`, so
there is one shape from the LiveKit participant to the host and nobody has to
subscribe through one Behavior to reach another.
```ts
export type MediaSource =
| "microphone"
| "camera"
| "screenShare"
| "screenShareAudio"
/** Published without a source; the application knows what it is. */
| "unknown";
export type MediaStreamStats =
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined;
/** One published track of a member. */
export interface MediaTrack {
source: MediaSource;
kind: "audio" | "video";
/** Stable for the life of the track. */
id: string;
muted$: Behavior<boolean>;
/** False when the SFU reports the track as unencrypted. Fixed for the life of the track. */
encrypted: boolean;
/**
* Inbound statistics for a remote track, outbound for a local one, polled
* every `intervalMs` while the observable is subscribed and not at all
* otherwise, so a statistics panel costs nothing while closed.
*/
stats$(intervalMs: number): Observable<MediaStreamStats>;
/**
* Rendering. The view hands its <video> or <audio> element over; the SDK
* sets its stream and, for video, registers the size and on-screen
* observers that pick a simulcast layer and pause the subscription while
* the element is hidden. attach is idempotent per element; detach has to be
* called before the element leaves the DOM so those observers are released.
*/
attach(element: HTMLMediaElement): void;
detach(element: HTMLMediaElement): void;
}
export interface AudioMediaTrack extends MediaTrack {
kind: "audio";
/**
* Whether the track carries sound right now, as the backend measures it.
* False while muted. A call reads this on the microphone track and calls it
* "speaking". LiveKit measures it per member, so every audio track of a
* member reports the same value.
*/
isActive$: Behavior<boolean>;
/**
* How loud the track is right now, 0 to 1, as the backend measures it. 0
* while muted. Not what decides `isActive$`: the backend applies its own
* threshold and hysteresis to that.
*/
audioLevel$: Behavior<number>;
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
/** No-op for our own audio, which is never played back. */
setVolume(volume: number): void;
}
export interface VideoMediaTrack extends MediaTrack {
kind: "video";
}
/** The controls a member has over a track it publishes itself. */
export interface LocalMediaTrack {
/** Mutes or unmutes. Resolves with the state that resulted. */
setEnabled(enabled: boolean): Promise<boolean>;
/** Captures from another device. Rejects for a screen share, which has none. */
setDevice(deviceId: string): Promise<void>;
}
export interface LocalAudioMediaTrack
extends AudioMediaTrack, LocalMediaTrack {}
export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
/** For mirroring; undefined where the camera does not say which way it faces. */
facingMode$: Behavior<"user" | "environment" | undefined>;
/** Restarts the camera facing the other way; resolves with the device now in use. */
switchFacingMode(): Promise<string | undefined>;
/** Background blur and the like; undefined removes the processor. */
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
}
export type EncryptionError = "MissingKey" | "InvalidKey";
/** The member's first track of a source; undefined while there is none, or no media yet. */
export function trackBySource$<S extends MediaSource>(
scope: ObservableScope,
tracks$: Behavior<MediaTrack[] | null>,
source: S,
): Behavior<TrackOfSource<S> | undefined>;
```
### Rendering: `attach` and `detach`
The view passes its DOM element into the SDK and the SDK does the rest; there is no
`MediaStreamTrack` on the interface. The reason is adaptive stream: Element Call
runs with `adaptiveStream: true`, which means LiveKit decides the simulcast layer
from the size of the attached video element and pauses the subscription at the
SFU while no attached element is on screen. Those size and visibility observers are
set up inside LiveKit's `attach`. Handing the element over is the only way to keep
that without reimplementing the observers outside the SDK. The e2e tests check both
effects: a larger tile receives a higher resolution, a hidden tile stops receiving
frames.
What `attach` covers for free, so the host does not have to: `srcObject` and
`autoplay` setup with the Safari and Firefox black-video workarounds; `play()` and
the autoplay-blocked signal; following the track through mute, reconnect and
re-subscription into every attached element; the Web Audio graph for earpiece pan
and gain; the adaptive stream observers.
The interface is web-only. A non-DOM host would need a different media surface; if
one shows up, a `MediaStreamTrack` accessor is added next to `attach`, not instead
of it.
## Packaging
`sdk/` is packaged exactly as `component/` is: a `package.json` with peer
dependencies (`matrix-js-sdk`, `livekit-client`, `rxjs`), `exports` with types,
type emission through `sdk/tsconfig.build.json`, an externals list in
`vite-sdk.config.ts` that `pnpm lint:externals` enforces, and installation from the
repository as a git dependency with a `path:` suffix. `pnpm build:sdk` produces
`sdk/dist`. It is not a pnpm workspace package yet; the root is not a workspace, and
moving to one later carries the package name, entry point and externals over
unchanged.
Element Call consumes the SDK by its package name from day one, so the swap to a
published dependency is one line: `tsconfig.json` `paths` and the `resolve.alias`
in `vitePluginsConfig` both map `@element-hq/matrixrtc-sdk` to `sdk/index.ts`. The
alias has to live in `vitePluginsConfig`, which every config is built on. Put only
into the app's own config, the harness resolves the name through `sdk/package.json`
to whatever stale `sdk/dist` is lying around.
Layout:
```
sdk/
index.ts the entry point: re-exports the API and the primitives
src/api.ts the public types above: client, members, transports
src/media-api.ts the public media types: publish requests, tracks
src/errors.ts MatrixRTCError and its codes
src/config.ts MatrixRTCMode, session timings, media quality
src/encryption.ts E2eeType, EncryptionSystem
src/MatrixRTCClient.ts createMatrixRTCClient: wires the two folders below
src/participationState.ts the membership state combined with the media connection
src/reactive/ Behavior, ObservableScope, the observable operators
src/matrixrtc/ the MatrixRTC side: the local membership, memberships, member
metadata, transports, discovery, the js-sdk join
src/media-backend/ the MediaBackend interface in api.ts; livekit/ is the one
backend: connections, publisher, JWT, keys, the tracks
src/utils/ mapScoped, network retry, display names, test helpers
dev/ the harness (below)
SdkArchitecture.md this document
SdkMigration.md what moved from Element Call's src/ into the SDK
```
The lint rule `element-call/sdk-import-boundary` keeps it that way: nothing under
`sdk/` may import a file outside `sdk/` (that is `src/`, `component/`,
`playwright/`) or React, i18n or Compound. The harness under `sdk/dev/` is a host
and exempt.
## Dev harness: `sdk/dev/`
A web app that uses the SDK and nothing else: plain DOM, no React, no Compound, no
i18n, imports only `@element-hq/matrixrtc-sdk`, `matrix-js-sdk` and `rxjs`. It is
where the SDK is run during development and what the e2e tests in `playwright/sdk`
drive. If something cannot be done in the harness without reaching into `src/`, the
SDK API is missing something.
What it does: log in with a password, or with an access token the URL hands it (the
tests register users through the admin API and skip `/login`, which the dev
homeserver rate-limits); join the room and wait for the sync to hold it; create the
client with microphone and camera enabled; show one tile per member with the display
name, a `<video>` attached from the member's `"camera"` track and, for remote
members, an `<audio>` from the `"microphone"` track, both picked out of
`tracks$` with `trackBySource$`; toggle the microphone and camera; leave.
Every media element carries the track's state as `data-*` attributes (`muted`,
`encrypted`, `active`, `frameWidth`, `frames`), which is what the tests read to
check mute propagation, encryption, adaptive resolution and paused subscriptions
(the speaker detection check is a `fixme`: the fake microphones do not reliably
register at the SFU). That is more
than the smallest consumer needs, and the price of testing the SDK through its
public API only.
## Open decisions
- **Publishing before `join()`.** The initial requests are published at the
join; creating the tracks earlier, for a lobby preview, is still missing.
- **`applicationData` after the join.** The option is read once; there is no
way to update it on a live membership yet.
- **Raw membership state.** The Matrix side's own state machine
(`MembershipState` in `LocalMembership.ts`) stays internal; `state$` on the
participation is its public view combined with the media connection. A
developer panel may want it as `LocalRTCMember.membershipState$`.
- **Developer panel.** `resolved$` on each connected transport covers what the
panel shows today. Anything beyond that (LiveKit room state, connection
quality) needs an opaque `debug$` per transport.
- **Data beside the media.** `sendData`/`data$` ride on LiveKit's reliable data
packets: topic-agnostic, matched to members by identity, received on every
connection but sent only on the local transport, so a member on another
transport does not hear us. They are transport-encrypted, not end to end:
LiveKit can encrypt data packets with the media key, but that is not wired
through the key provider yet. A host that needs either reach or end-to-end
encryption uses room events.
- **Unencrypted rooms with per-participant keys.** Keys only reach devices the
crypto tracks, which are the members of encrypted rooms. If the SDK is to work
in an unencrypted room, tracking the members' devices becomes its job.
+53
View File
@@ -0,0 +1,53 @@
# What moved from `src/` into the SDK
Element Call's `CallViewModel` used to do everything; now it consumes the SDK and
keeps only what makes a session a call. This is the inventory of what moved out
of `src/` on the way, so that a reader of either side knows where to look. It
names source files, so it ages with the code; the shape they make is in
[`SdkArchitecture.md`](./SdkArchitecture.md).
Everything in the table is gone from `src/`. Each module came with its tests;
`sdk/src/utils/test.ts` and `test-fixtures.ts` are the subset of Element Call's
test helpers they need. `Behavior`, `ObservableScope`, the observable operators,
`E2eeType`, `EncryptionSystem` and `MatrixRTCMode` are re-exported from the SDK
by their old `src/` paths, so the rest of Element Call did not have to change.
| Was in `src/` | Is in `sdk/src/` | What changed on the way |
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `state/Behavior.ts`, `state/ObservableScope.ts`, `utils/observable.ts` | `reactive/` | as is |
| `state/SessionBehaviors.ts` | `matrixrtc/JsRtcSessionBehaviors.ts` | the memberships and key rotation behaviors only |
| `state/CallViewModel/localMember/HomeserverConnected.ts` | `matrixrtc/HomeserverConnected.ts` | the grace period defaults to `defaultSessionTimings` instead of `Config.get()` |
| `state/CallViewModel/localMember/LocalMember.ts` | `matrixrtc/LocalMembership.ts`, `media-backend/livekit/LocalMedia.ts`, `participationState.ts` | split into the Matrix side, the media side and the state machine that combines them |
| `state/CallViewModel/localMember/LocalTransport.ts`, `RtcTransportAutoDiscovery.ts` | `matrixrtc/LocalTransport.ts`, `TransportAutoDiscovery.ts` | the override and the fallback are transports from the options, not a setting and a resolved config |
| `state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts` | `media-backend/livekit/LivekitBackend.ts` (`mediaFor`) | matches participants to members inside the backend; the SDK hands out `RTCMember`s |
| `state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts`, `utils/displayname.ts` | `matrixrtc/MatrixMemberMetadata.ts`, `utils/displayname.ts` | as is |
| `state/CallViewModel/remoteMembers/Connection.ts` | `media-backend/livekit/Connection.ts` | throws `MatrixRTCError`s; `ConnectionState` is a discriminated union; fetches its own token for its role |
| `state/CallViewModel/remoteMembers/ConnectionFactory.ts` | `media-backend/livekit/ConnectionFactory.ts` | `LivekitConnectionFactory` builds the room options from the publish requests and `mediaQuality`, not from settings |
| `state/CallViewModel/remoteMembers/ConnectionManager.ts` | `media-backend/livekit/ConnectionMap.ts` | keyed by `TransportLocator`; the publisher role is the local transport's |
| `state/CallViewModel/localMember/Publisher.ts` | `media-backend/livekit/Publisher.ts` | rewritten on publish requests instead of `MuteStates` |
| `livekit/openIDSFU.ts`, later `livekit/auth/` | `media-backend/livekit/auth/` | the flow is chosen by the transport's shape and our role; the delegated leave timeout is a parameter |
| `livekit/options.ts` | `media-backend/livekit/livekitOptions.ts` | `buildLiveKitOptions(mediaQuality?)` only; the `Config` singleton is gone |
| `e2ee/matrixKeyProvider.ts`, the key provider selection | `media-backend/livekit/MatrixKeyProvider.ts`, `KeyProvider.ts` | as is; selection keyed off `encryptionSystem` |
| `e2ee/e2eeType.ts`, `EncryptionSystem` from `e2ee/sharedKeyManagement.ts` | `encryption.ts` | the React hooks stayed behind |
| `MatrixRTCMode`, the timings and media quality types from `config/ConfigOptions.ts` | `config.ts` | `defaultSessionTimings` and `defaultMediaQuality` replace `DEFAULT_CONFIG` |
| `utils/errors.ts` | `errors.ts` | `MatrixRTCError` with `code` and `category` replaces `ElementCallError`; messages are plain English, the host translates by code |
| `doNetworkOperationWithRetry` from `utils/matrix.ts` | `utils/network.ts` | takes a `shouldRetry` predicate |
| the join in `CallViewModel.ts` (`enterRTCSession`) | `matrixrtc/joinJsSdkSession.ts` | the timings and the delayed leave variant are parameters |
| `livekit/MatrixAudioRenderer.tsx`, the participant reads in `state/media/` | `media-backend/livekit/LivekitMediaTrack.ts`, `LivekitMemberMedia.ts` | the view models get `MediaTrack`s with `attach`; nothing in `src/` imports a LiveKit participant any more |
## What still binds Element Call to LiveKit
Each is a gap in the SDK's API rather than something to rename; the import goes
away when the SDK grows the piece. The names Element Call owes to the outside
stay as they are: the `livekit.livekit_service_url` key in `config.json`, the
persisted setting keys `extended-livekit-logs` and `custom-transport`, the
Posthog fields `callBackend` and `callReconnectingCountLivekit`, the rageshake
field `call_backend`, and the translated error strings.
| Gap | Where it shows |
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Preview before `join()`: local tracks for the lobby, handed out as `MediaTrack`s | `LobbyView` (`usePreviewTracks`), `VideoPreview` (`LocalVideoTrack`, `facingModeFromLocalTrack`) |
| Device enumeration without LiveKit's helpers | `MediaDevices` (`createMediaDeviceObserver`), `CallView` (`Room.getLocalDevices`) |
| A `logging` option: log level and a sink for the transport's logs | `initializer.tsx`, the `extended-livekit-logs` setting |
| A `capabilities` field: output selection and background processors, asked of the SDK rather than of LiveKit | `CallFooterViewModel` (`supportsAudioOutputSelection`, `supportsBackgroundProcessors`) |
| Element Call's error and analytics types on the SDK's | `PosthogEvents` (`DisconnectReason`), `errors.ts` (`ConnectionError`, `LivekitConnectionError`) |
+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<!--
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MatrixRTC SDK harness</title>
<style>
body {
margin: 1rem;
font-family: system-ui, sans-serif;
}
form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
#members {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
#members section {
width: 240px;
}
#members video {
width: 100%;
aspect-ratio: 4 / 3;
background: #222;
}
#members h2 {
font-size: 1rem;
margin: 0.25rem 0;
}
</style>
</head>
<body>
<form>
<input
name="homeserver"
placeholder="homeserver"
value="https://synapse.m.localhost"
/>
<input name="username" placeholder="username" />
<input name="password" type="password" placeholder="password" />
<input name="room" placeholder="room id or alias" />
<!-- A host that already holds a session passes it instead of a password -->
<input name="accessToken" type="hidden" />
<input name="userId" type="hidden" />
<input name="deviceId" type="hidden" />
<button type="submit">Start</button>
<button type="button" id="microphone" aria-pressed="true" hidden>
Microphone
</button>
<button type="button" id="camera" aria-pressed="true" hidden>
Camera
</button>
<button type="button" id="screenShare" aria-pressed="false" hidden>
Share screen
</button>
<button type="button" id="leave" hidden>Leave</button>
</form>
<p id="status" data-testid="status"></p>
<!-- A line of text to the other members, beside the media -->
<form id="data" hidden>
<input name="text" placeholder="message" />
<button type="submit">Send</button>
</form>
<ul id="messages"></ul>
<main id="members"></main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+289
View File
@@ -0,0 +1,289 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* The smallest consumer of the SDK: log in, join a room, show every member's
* camera and play every remote member's microphone, mute, leave. What a host
* writes, and nothing a host would not.
*/
import { logger } from "matrix-js-sdk/lib/logger";
import { combineLatest, type Observable, of, switchMap } from "rxjs";
import {
type AudioMediaTrack,
createRTCSlot,
E2eeType,
type LocalRTCMember,
MatrixRTCMode,
ObservableScope,
type RemoteRTCMember,
type RTCParticipation,
trackBySource$,
type VideoMediaTrack,
} from "@element-hq/matrixrtc-sdk";
import { createSession, joinRoom, type Login } from "./session";
const form = document.querySelector("form")!;
const status = document.getElementById("status")!;
const members = document.getElementById("members")!;
const dataForm = document.getElementById("data") as HTMLFormElement;
const messages = document.getElementById("messages")!;
const buttons = {
microphone: document.getElementById("microphone") as HTMLButtonElement,
camera: document.getElementById("camera") as HTMLButtonElement,
screenShare: document.getElementById("screenShare") as HTMLButtonElement,
leave: document.getElementById("leave") as HTMLButtonElement,
};
// So that a test, or a bookmark, can fill the form from the URL
const params = new URLSearchParams(location.search);
for (const input of form.querySelectorAll("input"))
input.value = params.get(input.name) ?? input.value;
form.addEventListener("submit", (event) => {
event.preventDefault();
const fields = new FormData(form);
const field = (name: string): string => fields.get(name) as string;
const login: Login = field("accessToken")
? {
accessToken: field("accessToken"),
userId: field("userId"),
deviceId: field("deviceId"),
}
: { username: field("username"), password: field("password") };
void start(field("homeserver"), login, field("room"));
});
async function start(
homeserver: string,
login: Login,
roomIdOrAlias: string,
): Promise<void> {
try {
status.textContent = "Logging in";
const client = await createSession(homeserver, login);
const room = await joinRoom(client, roomIdOrAlias);
const scope = new ObservableScope();
const slot = createRTCSlot(scope, client, room, {
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
matrixRTCMode: MatrixRTCMode.Compatibility,
});
const rtcParticipation = slot.join({
publish: [{ source: "microphone" }, { source: "camera" }],
});
rtcParticipation.state$.pipe(scope.bind()).subscribe((state) => {
status.textContent =
state.kind === "failed" ? `Error: ${state.error.message}` : state.kind;
});
showMembers(scope, rtcParticipation);
showMessages(scope, rtcParticipation);
toggle(buttons.microphone, async (enabled) =>
setPublished(rtcParticipation, "microphone", enabled),
);
toggle(buttons.camera, async (enabled) =>
setPublished(rtcParticipation, "camera", enabled),
);
toggle(buttons.screenShare, async (enabled) =>
setPublished(rtcParticipation, "screenShare", enabled),
);
buttons.leave.hidden = false;
buttons.leave.onclick = (): void => {
rtcParticipation.leave();
scope.end();
members.replaceChildren();
messages.replaceChildren();
dataForm.hidden = true;
for (const button of Object.values(buttons)) button.hidden = true;
status.textContent = "Left";
};
} catch (e) {
status.textContent = `Error: ${e}`;
logger.error(e);
}
}
/**
* Turns one of our sources on or off: a microphone or camera is muted and
* unmuted while it exists and published when it does not; a screen share is
* published and unpublished.
*/
async function setPublished(
rtcParticipation: RTCParticipation,
source: "microphone" | "camera" | "screenShare",
enabled: boolean,
): Promise<boolean> {
const track = rtcParticipation.localMember$.value?.tracks$.value?.find(
(t) => t.source === source,
);
if (track && source === "screenShare") {
if (!enabled) await rtcParticipation.unpublish(source);
return enabled;
}
if (track) return track.setEnabled(enabled);
if (!enabled) return false;
await rtcParticipation.publish({ source });
return true;
}
/** A pressed button means the source is on; the SDK says what it could do. */
function toggle(
button: HTMLButtonElement,
set: (enabled: boolean) => Promise<boolean>,
): void {
button.hidden = false;
button.onclick = (): void => {
void set(button.ariaPressed !== "true").then((enabled) => {
button.ariaPressed = String(enabled);
});
};
}
/** Sends what the form holds on the "chat" topic, and lists what arrives. */
function showMessages(
scope: ObservableScope,
rtcParticipation: RTCParticipation,
): void {
dataForm.hidden = false;
dataForm.onsubmit = (event): void => {
event.preventDefault();
const text = new FormData(dataForm).get("text") as string;
rtcParticipation.sendData("chat", text).then(
() => dataForm.reset(),
(e: unknown) => {
status.textContent = `Error: ${e}`;
},
);
};
rtcParticipation.data$
.pipe(scope.bind())
.subscribe(({ member, topic, text }) => {
const line = messages.appendChild(document.createElement("li"));
line.dataset.testid = "message";
line.dataset.topic = topic;
line.dataset.userId = member.userId;
line.textContent = `${member.displayName$.value}: ${text}`;
});
}
function showMembers(
scope: ObservableScope,
rtcParticipation: RTCParticipation,
): void {
const tiles = new Map<string, HTMLElement>();
combineLatest([
rtcParticipation.localMember$,
rtcParticipation.remoteMembers$,
])
.pipe(scope.bind())
.subscribe(([local, remote]) => {
const current = local === null ? remote : [local, ...remote];
for (const [id, tile] of tiles)
if (!current.some((m) => m.memberId === id)) {
tile.remove();
tiles.delete(id);
}
for (const member of current)
if (!tiles.has(member.memberId)) {
const tile = memberTile(scope, member);
tiles.set(member.memberId, tile);
members.append(tile);
}
});
}
function memberTile(
scope: ObservableScope,
member: LocalRTCMember | RemoteRTCMember,
): HTMLElement {
const tile = document.createElement("section");
tile.dataset.testid = "member";
tile.dataset.userId = member.userId;
tile.dataset.local = String(member.local);
const name = tile.appendChild(document.createElement("h2"));
member.displayName$.pipe(scope.bind()).subscribe((n) => {
name.textContent = n;
});
render(
scope,
trackBySource$(scope, member.tracks$, "camera"),
tile.appendChild(document.createElement("video")),
);
// Our own microphone would only echo
if (!member.local)
render(
scope,
trackBySource$(scope, member.tracks$, "microphone"),
tile.appendChild(document.createElement("audio")),
);
return tile;
}
/**
* Plays a track on an element and labels the element with the track's state,
* so that the page shows what the member is sending and how well it arrives.
*/
function render(
scope: ObservableScope,
track$: Observable<AudioMediaTrack | VideoMediaTrack | undefined>,
element: HTMLMediaElement,
): void {
let attached: AudioMediaTrack | VideoMediaTrack | undefined;
track$.pipe(scope.bind()).subscribe((track) => {
attached?.detach(element);
attached = track;
track?.attach(element);
});
scope.onEnd(() => attached?.detach(element));
const label = (
key: string,
value$: Observable<string | number | boolean | undefined>,
): void => {
value$.pipe(scope.bind()).subscribe((value) => {
if (value === undefined) delete element.dataset[key];
else element.dataset[key] = String(value);
});
};
const of$ = <T>(
pick: (track: AudioMediaTrack | VideoMediaTrack) => Observable<T>,
): Observable<T | undefined> =>
track$.pipe(switchMap((track) => (track ? pick(track) : of(undefined))));
label(
"muted",
of$((t) => t.muted$),
);
label(
"encrypted",
of$((t) => of(t.encrypted)),
);
label(
"active",
of$((t) => (t.kind === "audio" ? t.isActive$ : of(undefined))),
);
const stats$ = of$((t) => t.stats$(1000));
label("frameWidth", stats$.pipe(switchMap((s) => of(frames(s)?.frameWidth))));
label("frames", stats$.pipe(switchMap((s) => of(frames(s)?.count))));
}
/** The frame counters an RTP stream reports, from either end of it. */
function frames(
stats: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined,
): { frameWidth: number | undefined; count: number | undefined } | undefined {
if (stats === undefined) return undefined;
return {
frameWidth: stats.frameWidth,
count:
stats.type === "inbound-rtp"
? (stats as RTCInboundRtpStreamStats).framesDecoded
: (stats as RTCOutboundRtpStreamStats).framesEncoded,
};
}
+99
View File
@@ -0,0 +1,99 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
ClientEvent,
createClient,
type MatrixClient,
MemoryStore,
type Room,
SyncState,
} from "matrix-js-sdk";
import { KnownMembership } from "matrix-js-sdk/lib/types";
export type Login =
| { username: string; password: string }
| { accessToken: string; userId: string; deviceId: string };
/**
* Returns a client that has finished its first sync, logging in with a
* password unless the caller already holds a token.
*/
export async function createSession(
homeserver: string,
login: Login,
): Promise<MatrixClient> {
const credentials =
"accessToken" in login ? login : await logIn(homeserver, login);
const client = createClient({
baseUrl: homeserver,
...credentials,
store: new MemoryStore(),
useAuthorizationHeader: true,
fallbackICEServerAllowed: true,
});
await client.initRustCrypto({ useIndexedDB: false });
await client.startClient();
await new Promise<void>((resolve) => {
const onSync = (state: SyncState): void => {
if (state !== SyncState.Prepared && state !== SyncState.Syncing) return;
client.off(ClientEvent.Sync, onSync);
resolve();
};
client.on(ClientEvent.Sync, onSync);
});
return client;
}
/**
* Joins a room and returns it as the sync loop maintains it. The room object
* `joinRoom` itself returns for a room joined just now is a detached copy
* that never receives the state the sync delivers, and a session built on
* it would never see a member.
*/
export async function joinRoom(
client: MatrixClient,
roomIdOrAlias: string,
): Promise<Room> {
const { roomId } = await client.joinRoom(roomIdOrAlias);
const joinedRoom = (): Room | undefined => {
const room = client.getRoom(roomId);
return room?.hasMembershipState(client.getUserId()!, KnownMembership.Join)
? room
: undefined;
};
return (
joinedRoom() ??
new Promise<Room>((resolve) => {
const onSync = (): void => {
const room = joinedRoom();
if (room === undefined) return;
client.off(ClientEvent.Sync, onSync);
resolve(room);
};
client.on(ClientEvent.Sync, onSync);
})
);
}
async function logIn(
homeserver: string,
{ username, password }: { username: string; password: string },
): Promise<{ accessToken: string; userId: string; deviceId: string }> {
const login = await createClient({ baseUrl: homeserver }).login(
"m.login.password",
{ identifier: { type: "m.id.user", user: username }, password },
);
return {
accessToken: login.access_token,
userId: login.user_id,
deviceId: login.device_id,
};
}
-51
View File
@@ -1,51 +0,0 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* This file contains helper functions and types for the MatrixRTC SDK.
*/
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { scan } from "rxjs";
import { type WidgetHelpers } from "../src/widget";
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
export const tryMakeSticky = (widget: WidgetHelpers): void => {
const logger = rootLogger.getChild("[MatrixRTCSdk]");
logger.info("try making sticky MatrixRTCSdk");
void widget.api
.setAlwaysOnScreen(true)
.then(() => {
logger.info("sticky MatrixRTCSdk");
})
.catch((error) => {
logger.error("failed to make sticky MatrixRTCSdk", error);
});
};
export const TEXT_LK_TOPIC = "matrixRTC";
/**
* simple helper operator to combine the last emitted and the current emitted value of a rxjs observable
*
* I think there should be a builtin for this but i did not find it...
*/
export const currentAndPrev = scan<
LivekitRoomItem[],
{
prev: LivekitRoomItem[];
current: LivekitRoomItem[];
}
>(
({ current: lastCurrentVal }, items) => ({
prev: lastCurrentVal,
current: items,
}),
{
prev: [],
current: [],
},
);
+70
View File
@@ -0,0 +1,70 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* EXPERIMENTAL
*
* MatrixRTC sessions with LiveKit media, without Element Call's UI: the call
* model Element Call's own view model is built on, for hosts that want to
* build a different one. The design is in `SdkArchitecture.md` next to this
* file.
*/
export { type Behavior, constant } from "./src/reactive/Behavior";
export {
Epoch,
mapEpoch,
ObservableScope,
trackEpoch,
} from "./src/reactive/ObservableScope";
export {
filterBehavior,
finalizeValue,
generateItems,
generateItemsWithEpoch,
pauseWhen,
} from "./src/reactive/observable";
export { E2eeType, type EncryptionSystem } from "./src/encryption";
export {
defaultMediaQuality,
defaultSessionTimings,
type DelayedLeaveTimings,
MatrixRTCMode,
type MediaQuality,
type SessionTimings,
} from "./src/config";
export {
ErrorCategory,
ErrorCode,
FailToGetOpenIdToken,
FailToStartLivekitConnection,
InsufficientCapacityError,
LivekitConnectionError,
MatrixRTCTransportMissingError,
MembershipManagerError,
NoMatrix2AuthorizationService,
PeerConnectionTimeoutError,
SFURoomCreationRestrictedError,
UnknownRTCError,
} from "./src/errors";
export * from "./src/api";
export * from "./src/media-api";
export * from "./src/media-backend/api";
export { trackBySource$, type TrackOfSource } from "./src/utils/tracks";
export { createRTCSlot } from "./src/RTCSlot";
export {
createLivekitBackend,
type LivekitBackendOptions,
} from "./src/media-backend/livekit/LivekitBackend";
// For a host that creates LiveKit tracks itself, such as a preview before joining
export { convertToLivekitProcessor } from "./src/media-backend/livekit/videoProcessor";
// For a developer panel to check a transport before using it
export {
getSFUConfig as authenticateWithTransport,
type GetSFUConfigParams as AuthenticateWithTransportParams,
} from "./src/media-backend/livekit/auth";
-382
View File
@@ -1,382 +0,0 @@
/*
Copyright 2025-2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* EXPERIMENTAL
*
* This file is the entrypoint for the sdk build of element call: `pnpm build:sdk`
* use in widgets.
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
* can be used to join a rtc session and exchange realtime data.
* It takes care of all the tricky bits:
* - sending delayed events
* - finding the right sfu
* - handling the media stream
* - sending join/leave state or sticky events
* - setting up encryption and scharing keys
*/
import {
combineLatest,
map,
type Observable,
of,
shareReplay,
Subject,
switchMap,
tap,
} from "rxjs";
import {
type CallMembership,
MatrixRTCSessionEvent,
MatrixRTCSessionManager,
} from "matrix-js-sdk/lib/matrixrtc";
import {
type Room as LivekitRoom,
type TextStreamReader,
type LocalParticipant,
type RemoteParticipant,
} from "livekit-client";
// TODO how can this get fixed? to just be part of `livekit-client`
// Can this be done in the tsconfig.json
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
import { type Behavior, constant } from "../src/state/Behavior";
import {
callViewModelOptionsFromParams,
createCallViewModel$,
} from "../src/state/CallViewModel/CallViewModel";
import { ObservableScope } from "../src/state/ObservableScope";
import { getUrlParams } from "../src/UrlParams";
import { MuteStates } from "../src/state/MuteStates";
import { MediaDevices } from "../src/state/MediaDevices";
import { E2eeType } from "../src/e2ee/e2eeType";
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { initializeWidget } from "../src/widget";
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
import { createWidgetHostBridge } from "../src/HostBridge";
import { observeElementSize$ } from "../src/utils/elementSize";
interface MatrixRTCSdk {
/**
* observe connected$ to track the state.
* @returns
*/
join: () => void;
/** @throws on leave errors */
leave: () => void;
/**
* Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope.
* No updates can be received from the rtc sdk. The sdk cannot be restarted after.
* A new sdk needs to be created via createMatrixRTCSdk.
*/
stop: () => void;
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
/**
* flattened list of remote members
*/
remoteMembers$: Behavior<
{
connection: Connection | null;
membership: CallMembership;
participant: LocalParticipant | RemoteParticipant | null;
}[]
>;
/**
* flattened local member
*/
localMember$: Behavior<{
connection: Connection | null;
membership: CallMembership;
participant: LocalParticipant | null;
} | null>;
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
connected$: Behavior<boolean>;
sendData?: (data: unknown) => Promise<void>;
sendRoomMessage?: (message: string) => Promise<void>;
}
export async function createMatrixRTCSdk(
application: string = "m.call",
id: string = "",
sticky: boolean = false,
): Promise<MatrixRTCSdk> {
const logger = rootLogger.getChild("[MatrixRTCSdk]");
const scope = new ObservableScope();
// widget client
const widget = initializeWidget(application, true);
if (!widget) throw Error("No widget. This webapp can only start as a widget");
const client = await widget.client;
const hostBridge = createWidgetHostBridge(widget);
logger.info("client created");
// url params
const urlParams = getUrlParams();
const { roomId, controlledAudioDevices, callIntent } = urlParams;
if (roomId === null) throw Error("could not get roomId from url params");
const room = client.getRoom(roomId);
if (room === null) throw Error("could not get room from client");
// rtc session
const slot = { application, id };
const rtcSessionManager = new MatrixRTCSessionManager(logger, client, slot);
rtcSessionManager.start();
const rtcSession = rtcSessionManager.getRoomSession(room);
// media devices
const mediaDevices = new MediaDevices(scope, {
controlledAudioDevices,
callIntent,
});
const muteStates = new MuteStates(
scope,
mediaDevices,
{ audioEnabled: false, videoEnabled: false },
hostBridge,
);
// call view model
const callViewModel = createCallViewModel$(
scope,
rtcSession,
room,
mediaDevices,
muteStates,
{
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
hostBridge,
// The SDK owns its page, so the body is the space it has
windowSize$: scope.behavior(observeElementSize$(document.body)),
},
of({}),
of({}),
constant({ supported: false, processor: undefined }),
);
logger.info("CallViewModelCreated");
// create data listener
const data$ = new Subject<{ rtcBackendIdentity: string; data: string }>();
const lkTextStreamHandlerFunction = async (
reader: TextStreamReader,
participantInfo: { identity: string },
livekitRoom: LivekitRoom,
): Promise<void> => {
const info = reader.info;
logger.info(
`Received text stream from ${participantInfo.identity}\n` +
` Topic: ${info.topic}\n` +
` Timestamp: ${info.timestamp}\n` +
` ID: ${info.id}\n` +
` Size: ${info.size}`, // Optional, only available if the stream was sent with `sendText`
);
const participants = callViewModel.livekitRoomItems$.value.find(
(i) => i.livekitRoom === livekitRoom,
)?.participants;
if (participants && participants.includes(participantInfo.identity)) {
const text = await reader.readAll();
logger.info(`Received text: ${text}`);
data$.next({ rtcBackendIdentity: participantInfo.identity, data: text });
} else {
logger.warn(
"Received text from unknown participant",
participantInfo.identity,
);
}
};
const livekitRoomItemsSub = callViewModel.livekitRoomItems$
.pipe(
tap((beforecurrentAndPrev) => {
logger.info(
`LiveKit room items updated: ${beforecurrentAndPrev.length}`,
beforecurrentAndPrev,
);
}),
currentAndPrev,
tap((aftercurrentAndPrev) => {
logger.info(
`LiveKit room items updated: ${aftercurrentAndPrev.current.length}, ${aftercurrentAndPrev.prev.length}`,
aftercurrentAndPrev,
);
}),
)
.subscribe({
next: ({ prev, current }) => {
const prevRooms = prev.map((i) => i.livekitRoom);
const currentRooms = current.map((i) => i.livekitRoom);
const addedRooms = currentRooms.filter((r) => !prevRooms.includes(r));
const removedRooms = prevRooms.filter((r) => !currentRooms.includes(r));
addedRooms.forEach((r) => {
logger.info(`Registering text stream handler for room `);
r.registerTextStreamHandler(
TEXT_LK_TOPIC,
(reader, participantInfo) =>
void lkTextStreamHandlerFunction(reader, participantInfo, r),
);
});
removedRooms.forEach((r) => {
logger.info(`Unregistering text stream handler for room `);
r.unregisterTextStreamHandler(TEXT_LK_TOPIC);
});
},
complete: () => {
logger.info("Livekit room items subscription completed");
for (const item of callViewModel.livekitRoomItems$.value) {
logger.info("unregistering room item from room", item.url);
item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC);
}
},
});
// create sendData function
const sendFn: Behavior<(data: string) => Promise<TextStreamInfo>> =
scope.behavior(
callViewModel.localMatrixLivekitMember$.pipe(
switchMap((m) => {
if (!m)
return of((data: string): never => {
throw Error("local membership not yet ready.");
});
return m.participant.value$.pipe(
map((p) => {
if (p === null) {
return (data: string): never => {
throw Error("local participant not yet ready to send data.");
};
} else {
return async (data: string): Promise<TextStreamInfo> =>
p.sendText(data, { topic: TEXT_LK_TOPIC });
}
}),
);
}),
),
);
const sendData = async (data: unknown): Promise<void> => {
const dataString = JSON.stringify(data);
logger.info("try sending: ", dataString);
try {
await Promise.resolve();
const info = await sendFn.value(dataString);
logger.info(`Sent text with stream ID: ${info.id}`);
} catch (e) {
logger.error("failed sending: ", dataString, e);
}
};
const sendRoomMessage = async (message: string): Promise<void> => {
const messageString = JSON.stringify(message);
logger.info("try sending to room: ", messageString);
try {
await client.sendTextMessage(room.roomId, message);
} catch (e) {
logger.error("failed sending to room: ", messageString, e);
}
};
// after hangup gets called
const leaveSubs = callViewModel.leave$.subscribe(() => {
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
const leaveResolver = Promise.withResolvers<void>();
logger.info("waiting for RTC leave");
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, (isJoined) => {
logger.info("received RTC join update: ", isJoined);
if (!isJoined) leaveResolver.resolve();
});
await leaveResolver.promise;
logger.info("send Unstick");
await hostBridge
.setAlwaysOnScreen(false)
.catch((e: unknown) =>
logger.error("Failed to set `alwaysOnScreen` to false", e),
);
logger.info("send Close");
await hostBridge
.close?.()
.catch((e: unknown) =>
logger.error("Failed to ask the host to close", e),
);
};
// schedule close first and then leave (scope.end)
void scheduleWidgetCloseOnLeave();
});
logger.info("createMatrixRTCSdk done");
return {
join: (): void => {
// first lets try making the widget sticky
if (sticky) tryMakeSticky(widget);
callViewModel.join();
},
leave: (): void => {
callViewModel.leave();
},
stop: (): void => {
leaveSubs.unsubscribe();
livekitRoomItemsSub.unsubscribe();
scope.end();
},
data$,
localMember$: scope.behavior(
callViewModel.localMatrixLivekitMember$.pipe(
tap((member) =>
logger.info("localMatrixLivekitMember$ next: ", member),
),
switchMap((member) => {
if (member === null) return of(null);
return combineLatest([
member.connection$,
member.membership$,
member.participant.value$,
]).pipe(
map(([connection, membership, participant]) => ({
connection,
membership,
participant,
})),
);
}),
tap((member) => logger.info("localMember$ next: ", member)),
),
),
connected$: callViewModel.connected$,
remoteMembers$: scope.behavior(
callViewModel.remoteMatrixLivekitMembers$.pipe(
switchMap((members) => {
const listOfMemberObservables = members.map((member) =>
combineLatest([
member.connection$,
member.membership$,
member.participant.value$,
]).pipe(
map(([connection, membership, participant]) => ({
connection,
membership,
participant,
})),
// using shareReplay instead of a Behavior here because the behavior would need
// a tricky scope.end() setup.
shareReplay({ bufferSize: 1, refCount: true }),
),
);
return combineLatest(listOfMemberObservables);
}),
),
[],
),
sendData,
sendRoomMessage,
};
}
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@element-hq/matrixrtc-sdk",
"version": "0.0.0",
"description": "MatrixRTC sessions with LiveKit media, without Element Call's UI. Consumed straight from the repository as a git dependency (github:element-hq/element-call#<ref>&path:/sdk): the host's package manager runs `prepare`, which builds `dist/`.",
"license": "SEE LICENSE IN ../README.md",
"repository": {
"type": "git",
"url": "https://github.com/element-hq/element-call",
"directory": "sdk"
},
"type": "module",
"devEngines": {
"packageManager": {
"name": "pnpm"
}
},
"files": [
"dist"
],
"main": "./dist/matrixrtc-sdk.js",
"module": "./dist/matrixrtc-sdk.js",
"types": "./dist/types/sdk/index.d.ts",
"exports": {
".": {
"types": "./dist/types/sdk/index.d.ts",
"default": "./dist/matrixrtc-sdk.js"
}
},
"scripts": {
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:sdk"
},
"peerDependencies": {
"livekit-client": "^2.18.1",
"matrix-js-sdk": "*",
"rxjs": "^7.8.1"
}
}
+14
View File
@@ -0,0 +1,14 @@
# Makes `sdk/` a pnpm project of its own rather than a directory inside the
# repository's workspace. That matters when a host installs the SDK straight
# from this repository (`github:element-hq/element-call#…&path:/sdk`): pnpm
# prepares such a dependency by running `pnpm install` in this directory, and
# only a project root gets its `prepare` script (see package.json) run, which is
# what builds `dist/`. Inside the repository's own workspace the install would
# silently target the repository instead and build nothing.
#
# Consequence for development: pnpm commands run from within this directory see
# this project, not the repository; run them from the repository root.
# Nothing to install here: the peers in package.json are the host's, and the
# build runs against the repository's own node_modules (see `prepare`).
autoInstallPeers: false
+445
View File
@@ -0,0 +1,445 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixClient, type Room, SyncState } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type CallMembership,
MatrixRTCSessionEvent,
MembershipManagerEvent,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, EMPTY, Subject } from "rxjs";
import { describe, expect, it, type Mock, vi } from "vitest";
import {
type DataMessage,
type ParticipationState,
type RTCMember,
type RTCParticipationOptions,
type RTCSlot,
type TransportMetadata,
} from "./api";
import { defaultSessionTimings, MatrixRTCMode } from "./config";
import { E2eeType } from "./encryption";
import { MatrixRTCError } from "./errors";
import {
type DataPacket,
type MediaBackend,
type MediaBackendContext,
MediaConnectionState,
type MediaKey,
} from "./media-backend/api";
import { type MemberMedia } from "./media-api";
import { constant } from "./reactive/Behavior";
import { ObservableScope } from "./reactive/ObservableScope";
import { createRTCParticipation } from "./RTCParticipation";
import { transportId } from "./matrixrtc/Transports";
import {
flushPromises,
mockEmitter,
mockRtcMembership,
MockRTCSession,
} from "./utils/test";
const transport: Transport = {
type: "livekit",
livekit_service_url: "https://lk.example.org",
};
const userId = "@alice:example.org";
/** A token with the publish grant, as a homeserver issues it for its own SFU. */
const publisherJWT = [
{},
{ sub: "identity", video: { room: "room", canPublish: true } },
{},
]
.map((d) => global.btoa(JSON.stringify(d)))
.join(".");
const deviceId = "AAAA";
const legacyMemberId = `${userId}:${deviceId}`;
function metadata(raw: Transport): TransportMetadata {
return {
type: raw.type,
id: transportId(raw),
raw,
resolved$: constant(undefined),
};
}
function member(
overrides: Partial<RTCMember> & Pick<RTCMember, "userId" | "memberId">,
): RTCMember {
return {
local: false,
rtcBackendIdentity: overrides.memberId,
backendIdentities: [overrides.memberId],
deviceId: "DEV",
transport$: constant(metadata(transport)),
applicationData$: constant({}),
displayName$: constant(overrides.userId),
avatarUrl$: constant(undefined),
...overrides,
};
}
const bob = member({ userId: "@bob:example.org", memberId: "bob-member" });
const own = member({ userId, deviceId, memberId: legacyMemberId, local: true });
/** A backend the test drives: it prepares when told, and reports what the participation asked of it. */
interface FakeBackend {
backend: MediaBackend;
context: MediaBackendContext;
prepared: PromiseWithResolvers<{ canDelegateDelayedLeave: boolean }>;
connectionState$: BehaviorSubject<MediaConnectionState | Error>;
data$: Subject<DataPacket>;
setPublishing: Mock<(publish: boolean) => void>;
delegateDelayedLeave: Mock<(delayId: string) => Promise<void>>;
mediaFor: Mock<(scope: ObservableScope, member: RTCMember) => MemberMedia>;
}
function setup(
options: Partial<RTCParticipationOptions> = {},
slotOptions: { matrixRTCMode?: MatrixRTCMode } = {},
{
homeserverTransports = [transport],
memberships = [],
}: {
homeserverTransports?: Transport[];
memberships?: CallMembership[];
} = {},
): {
scope: ObservableScope;
participation: ReturnType<typeof createRTCParticipation>["participation"];
session: MockRTCSession;
mockedSession: ReturnType<MockRTCSession["asMockedSession"]>;
join: ReturnType<typeof vi.spyOn>;
members$: BehaviorSubject<RTCMember[]>;
fake: FakeBackend;
states: ParticipationState[];
} {
const scope = new ObservableScope();
const room = { roomId: "!room:example.org" } as Room;
const session = new MockRTCSession(room, memberships);
const mockedSession = session.asMockedSession();
const join = vi.spyOn(session, "join");
const client = {
...mockEmitter(),
getUserId: () => userId,
getDeviceId: () => deviceId,
getDomain: () => "example.org",
getSyncState: () => SyncState.Syncing,
_unstable_getRTCTransports: vi.fn().mockResolvedValue(homeserverTransports),
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: publisherJWT }),
} as unknown as MatrixClient;
const members$ = new BehaviorSubject<RTCMember[]>([]);
const fake: Omit<FakeBackend, "backend" | "context"> &
Partial<Pick<FakeBackend, "backend" | "context">> = {
prepared: Promise.withResolvers(),
connectionState$: new BehaviorSubject<MediaConnectionState | Error>(
MediaConnectionState.Initialized,
),
data$: new Subject<DataPacket>(),
setPublishing: vi.fn(),
delegateDelayedLeave: vi.fn(async () => Promise.resolve()),
mediaFor: vi.fn(() => ({
tracks$: constant(null),
encryptionError$: EMPTY,
})),
};
const backendFactory = (
backendScope: ObservableScope,
context: MediaBackendContext,
): MediaBackend => {
const backend: MediaBackend = {
transportType: "livekit",
prepareLocalTransport: async () => fake.prepared.promise,
delegateDelayedLeave: fake.delegateDelayedLeave,
local: {
connectionState$: backendScope.behavior(fake.connectionState$),
setPublishing: fake.setPublishing,
publishError$: constant(null),
publish: vi.fn(),
unpublish: vi.fn(),
tracks$: constant(null),
encryptionError$: EMPTY,
},
mediaFor: fake.mediaFor,
connections$: constant([]),
setAudioOutputDeviceId: vi.fn(),
sendData: vi.fn(),
data$: fake.data$,
};
fake.context = context;
fake.backend = backend;
return backend;
};
const { participation } = createRTCParticipation(
scope,
{
client,
room,
slot: {} as RTCSlot,
jsSdkSession: mockedSession,
members$: scope.behavior(members$),
transports: { get: metadata, connected$: constant([]) },
slotOptions: {
matrixRTCMode: MatrixRTCMode.Compatibility,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
...slotOptions,
},
logger,
},
{ publish: [], backend: backendFactory, ...options },
);
const states: ParticipationState[] = [];
participation.state$.subscribe((state) => states.push(state));
return {
scope,
participation,
session,
mockedSession,
join,
members$,
fake: fake as FakeBackend,
states,
};
}
describe("createRTCParticipation", () => {
it("joins the session once the transport is prepared, and is connected once the media is", async () => {
const { participation, join, fake, states } = setup();
expect(states).toEqual([{ kind: "waitingForTransport" }]);
await flushPromises();
expect(join).not.toHaveBeenCalled();
fake.prepared.resolve({ canDelegateDelayedLeave: false });
await flushPromises();
expect(join).toHaveBeenCalledWith(
{ userId, deviceId, memberId: legacyMemberId },
[transport],
expect.objectContaining({
manageMediaKeys: true,
unstableSendStickyEvents: false,
delayedLeaveEventDelayMs: defaultSessionTimings.delayedLeave.delay_ms,
}),
);
expect(participation.state$.value).toEqual({ kind: "connecting" });
fake.connectionState$.next(MediaConnectionState.Connected);
expect(participation.state$.value).toEqual({ kind: "connected" });
fake.connectionState$.next(MediaConnectionState.Reconnecting);
expect(participation.state$.value).toEqual({
kind: "reconnecting",
reason: "media",
});
});
it("publishes on another member's SFU where the homeserver advertises none", async () => {
const bobOnSfu = mockRtcMembership("@bob:example.org", "BOB", {
fociPreferred: [{ type: "livekit", url: "wss://sfu.example.org" }],
});
const { join, fake } = setup(
{},
{},
{ homeserverTransports: [], memberships: [bobOnSfu] },
);
fake.prepared.resolve({ canDelegateDelayedLeave: false });
await flushPromises();
await flushPromises();
expect(join).toHaveBeenCalledWith(
expect.anything(),
[
{
type: "livekit",
url: "wss://sfu.example.org",
server_name: "example.org",
},
],
expect.anything(),
);
});
it("publishes only while joined", async () => {
const { participation, session, fake, scope } = setup();
const ended = vi.fn();
scope.onEnd(ended);
await flushPromises();
expect(fake.setPublishing).toHaveBeenLastCalledWith(false);
fake.prepared.resolve({ canDelegateDelayedLeave: false });
await flushPromises();
expect(fake.setPublishing).toHaveBeenLastCalledWith(true);
participation.leave();
expect(fake.setPublishing).toHaveBeenLastCalledWith(false);
expect(participation.state$.value).toEqual({ kind: "left" });
await flushPromises();
expect(session.leave).toHaveBeenCalled();
expect(ended).toHaveBeenCalled();
});
it("fails where the transport cannot be prepared", async () => {
const { participation, join, fake } = setup();
const error = new MatrixRTCError("no token");
fake.prepared.reject(error);
await flushPromises();
expect(participation.state$.value).toEqual({ kind: "failed", error });
expect(join).not.toHaveBeenCalled();
});
it("fails where the media connection fails, and stays failed", async () => {
const { participation, fake } = setup();
fake.prepared.resolve({ canDelegateDelayedLeave: false });
await flushPromises();
const error = new MatrixRTCError("SFU gone");
fake.connectionState$.next(error);
expect(participation.state$.value).toEqual({ kind: "failed", error });
fake.connectionState$.next(MediaConnectionState.Connected);
expect(participation.state$.value).toEqual({ kind: "failed", error });
});
it("joins with the delegated timings where the backend delegates, and hands it each delay id", async () => {
const { session, join, fake } = setup({
timings: { delegatedDelayedLeave: { delay_ms: 7_200_000 } },
});
fake.prepared.resolve({ canDelegateDelayedLeave: true });
await flushPromises();
expect(join).toHaveBeenCalledWith(
expect.anything(),
[transport],
expect.objectContaining({ delayedLeaveEventDelayMs: 7_200_000 }),
);
expect(fake.delegateDelayedLeave).not.toHaveBeenCalled();
// Re-emitted by the js-sdk session with the membership manager appended
(
session as unknown as { emit(event: string, ...args: unknown[]): void }
).emit(MembershipManagerEvent.DelayIdChanged, "delay-1", {});
await flushPromises();
expect(fake.delegateDelayedLeave).toHaveBeenCalledWith("delay-1");
});
it("names the membership `${userId}:${deviceId}` in compatibility mode and by a uuid in Matrix 2.0", () => {
const compatibility = setup();
expect(compatibility.fake.context.ownMembershipIdentity).toEqual({
userId,
deviceId,
memberId: legacyMemberId,
});
compatibility.scope.end();
const matrix2 = setup({}, { matrixRTCMode: MatrixRTCMode.Matrix_2_0 });
const { memberId } = matrix2.fake.context.ownMembershipIdentity;
expect(memberId).not.toBe(legacyMemberId);
expect(memberId).toMatch(/^[0-9a-f-]{36}$/);
matrix2.scope.end();
});
it("passes the slot and the join options on to the backend", () => {
const { fake, scope } = setup({
publish: [{ source: "camera" }],
timings: { networkErrorRetryMs: 250 },
});
expect(fake.context).toMatchObject({
roomId: "!room:example.org",
slotId: "m.call#ROOM",
publish: [{ source: "camera" }],
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
timings: { ...defaultSessionTimings, networkErrorRetryMs: 250 },
});
scope.end();
});
it("gives the backend the keys the session holds and the ones that follow", () => {
const { fake, session, mockedSession, scope } = setup();
const keys: MediaKey[] = [];
fake.context.mediaKeys$.subscribe((key) => keys.push(key));
expect(mockedSession.reemitEncryptionKeys).toHaveBeenCalledOnce();
const key = new Uint8Array([1, 2, 3]);
session.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
key,
2,
{ userId: "@bob:example.org", deviceId: "DEV", memberId: "bob-member" },
"bob-member",
);
expect(keys).toEqual([{ participantId: "bob-member", index: 2, key }]);
scope.end();
});
it("builds the remote members on the backend's media and the local member on our own membership", () => {
const { participation, members$, fake, scope } = setup();
const stale = member({
userId,
deviceId,
memberId: "old-uuid",
local: true,
});
members$.next([own, bob, stale]);
expect(fake.mediaFor).toHaveBeenCalledTimes(1);
expect(fake.mediaFor).toHaveBeenCalledWith(expect.anything(), bob);
expect(participation.remoteMembers$.value.map((m) => m.memberId)).toEqual([
"bob-member",
]);
expect(participation.localMember$.value).toMatchObject({
local: true,
memberId: legacyMemberId,
});
scope.end();
});
it("delivers data from a member with its member and drops the rest", () => {
const { participation, members$, fake, scope } = setup();
members$.next([
member({
userId: "@bob:example.org",
memberId: "bob-member",
backendIdentities: ["bob-hashed", "bob-member"],
}),
]);
const messages: DataMessage[] = [];
participation.data$.subscribe((message) => messages.push(message));
fake.data$.next({ senderId: "bob-hashed", topic: "chat", text: "hi" });
fake.data$.next({ senderId: "stranger", topic: "chat", text: "boo" });
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
topic: "chat",
text: "hi",
member: { memberId: "bob-member" },
});
scope.end();
});
it("leaves at most once", async () => {
const { participation, session, fake } = setup();
fake.prepared.resolve({ canDelegateDelayedLeave: false });
await flushPromises();
participation.leave();
participation.leave();
await flushPromises();
expect(session.leave).toHaveBeenCalledOnce();
});
});
+331
View File
@@ -0,0 +1,331 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixClient, type Room } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
type MatrixRTCSession as JsSdkRTCSession,
MatrixRTCSessionEvent,
MembershipManagerEvent,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { v4 as uuidv4 } from "uuid";
import {
combineLatest,
distinctUntilChanged,
filter,
from,
fromEvent,
map,
Observable,
scan,
} from "rxjs";
import { defaultSessionTimings, MatrixRTCMode } from "./config";
import { E2eeType } from "./encryption";
import { type Behavior } from "./reactive/Behavior";
import { type ObservableScope } from "./reactive/ObservableScope";
import { generateItems } from "./reactive/observable";
import { mapScoped } from "./utils/mapScoped";
import {
type DataMessage,
type LocalRTCMember,
type ParticipationState,
type RemoteRTCMember,
type RTCMember,
type RTCParticipation,
type RTCParticipationOptions,
type RTCSlot,
type RTCSlotOptions,
} from "./api";
import {
type MediaBackend,
type MediaBackendContext,
type MediaKey,
} from "./media-backend/api";
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
import { findRemotePublishLivekitTransport } from "./media-backend/livekit/RemotePublishTransport";
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
import { joinJsSdkSession } from "./matrixrtc/joinJsSdkSession";
import {
createLocalMembership$,
type PreparedTransport,
} from "./matrixrtc/LocalMembership";
import { discoverLocalTransport } from "./matrixrtc/LocalTransport";
import {
createLocalRTCMember,
createRemoteRTCMember,
} from "./matrixrtc/Members";
import { createKeyRotationSuppressed$ } from "./matrixrtc/JsRtcSessionBehaviors";
import { type TransportRegistry } from "./matrixrtc/Transports";
import { participationState } from "./participationState";
/** What the slot lends a participation. */
export interface ParticipationDeps {
client: MatrixClient;
room: Room;
slot: RTCSlot;
jsSdkSession: JsSdkRTCSession;
members$: Behavior<RTCMember[]>;
transports: TransportRegistry;
slotOptions: Pick<RTCSlotOptions, "matrixRTCMode" | "encryptionSystem">;
logger: Logger;
}
/** The participation, plus what the slot reads from it without exposing it. */
export interface ParticipationInternals {
participation: RTCParticipation;
connections$: MediaBackend["connections$"];
}
/**
* Our span in a slot: the backend, the local transport, the join state
* machine and the members with the media it carries for them. Starts joining
* at once and ends with `leave()`, which also ends `scope`.
*/
export function createRTCParticipation(
scope: ObservableScope,
{
client,
room,
slot,
jsSdkSession,
members$,
transports,
slotOptions: { matrixRTCMode, encryptionSystem },
logger: parentLogger,
}: ParticipationDeps,
options: RTCParticipationOptions,
): ParticipationInternals {
const logger = parentLogger.getChild("[RTCParticipation]");
const userId = client.getUserId()!;
const deviceId = client.getDeviceId()!;
const timings = { ...defaultSessionTimings, ...options.timings };
const ownMembershipIdentity: CallMembershipIdentityParts = {
userId,
deviceId,
// A pre-sticky membership names itself `${userId}:${deviceId}`, and the
// key transport stamps this id into every key event, so a uuid there
// would name a member no peer can resolve
memberId:
matrixRTCMode === MatrixRTCMode.Matrix_2_0
? uuidv4()
: `${userId}:${deviceId}`,
};
const backend = createBackend(scope, client, options, {
roomId: room.roomId,
slotId: jsSdkSession.slotId,
ownMembershipIdentity,
publish: options.publish,
encryptionSystem,
mediaKeys$: mediaKeys$(jsSdkSession),
timings,
logger,
});
// If the backend is LiveKit, we check it for transports as well, as a
// fallback: another member's SFU may let us publish (MSC4195). LiveKit only,
// so it stays out of the backend interface
const transportFromBackends = async (): Promise<Transport | null> =>
backend.transportType === "livekit"
? findRemotePublishLivekitTransport({
client,
// A snapshot: if nobody with a usable SFU is here yet, the user has
// to come back later rather than wait in a half-working lobby
memberships: jsSdkSession.memberships,
ownMembershipIdentity,
roomId: room.roomId,
slotId: jsSdkSession.slotId,
logger,
})
: null;
const preparedTransport$ = from(
discoverLocalTransport(
client,
backend.transportType,
options,
transportFromBackends,
logger,
).then(async (transport): Promise<PreparedTransport> => ({
transport,
...(await backend.prepareLocalTransport(transport)),
})),
);
const localMembership = createLocalMembership$({
scope,
delegateDelayedLeave: async (delayId) =>
backend.delegateDelayedLeave(delayId),
preparedTransport$,
homeserverConnected: createHomeserverConnected$(
scope,
client,
jsSdkSession,
timings.syncDisconnectGracePeriodMs,
),
joinMatrixRTC: (transport, delayedLeave) =>
joinJsSdkSession(jsSdkSession, ownMembershipIdentity, transport, {
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
matrixRTCMode,
sendNotificationType: options.sendNotificationType,
applicationData: options.applicationData,
timings,
delayedLeave,
}),
membershipManagerError$: fromEvent(
jsSdkSession,
MatrixRTCSessionEvent.MembershipManagerError,
),
matrixRTCSession: jsSdkSession,
delayId$: scope.behavior(
(
fromEvent(
jsSdkSession,
MembershipManagerEvent.DelayIdChanged,
// The re-emitted event carries the original emitter as the second argument
) as Observable<[string | undefined, IMembershipManager]>
).pipe(map(([delayId]) => delayId ?? null)),
jsSdkSession.delayId ?? null,
),
timings,
logger,
});
const remoteMembers$ = scope.behavior<RemoteRTCMember[]>(
members$.pipe(
generateItems(
"RTCParticipation remoteMembers",
function* (members) {
for (const member of members)
if (!member.local) yield { keys: [member] as const, data: member };
},
(memberScope, member$) =>
createRemoteRTCMember(
member$.value,
backend.mediaFor(memberScope, member$.value),
),
),
),
);
// Matched by the identity we joined with: a stale membership of ours from
// an earlier participation is `local` in the slot but is not this one
const ownMember$ = scope.behavior(
members$.pipe(
map(
(members) =>
members.find(
(member) =>
member.local &&
member.memberId === ownMembershipIdentity.memberId,
) ?? null,
),
),
);
const localMember$ = scope.behavior<LocalRTCMember | null>(
mapScoped(scope, ownMember$, (_memberScope, member) =>
createLocalRTCMember(member, backend.local),
).pipe(map((member) => member ?? null)),
);
// A packet from an identity that is not a member is dropped, so a host only
// ever hears from attested members
const data$ = backend.data$.pipe(
map(({ senderId, topic, text }): DataMessage | null => {
const member = remoteMembers$.value.find((m) =>
m.backendIdentities.includes(senderId),
);
if (member === undefined) {
logger.warn(`Dropping data from ${senderId}: not a member`);
return null;
}
return { member, topic, text };
}),
filter((message) => message !== null),
);
// Nothing leaves this device while it may already have been dropped from
// the session: the member would show as away while still being heard
localMembership.state$
.pipe(
map((membership) => membership.kind === "joined"),
distinctUntilChanged(),
scope.bind(),
)
.subscribe((publish) => backend.local.setPublishing(publish));
const state$ = scope.behavior<ParticipationState>(
combineLatest([
localMembership.state$,
backend.local.connectionState$,
]).pipe(
scan(
(previous, [membership, media]) =>
participationState(previous, membership, media),
{ kind: "waitingForTransport" } as ParticipationState,
),
distinctUntilChanged(deepCompare),
),
);
const leave = (): void => {
if (state$.value.kind === "left") return;
localMembership.leave();
scope.end();
};
return {
connections$: backend.connections$,
participation: {
slot,
leave,
state$,
localMember$,
remoteMembers$,
publish: async (request) => backend.local.publish(request),
unpublish: async (source) => backend.local.unpublish(source),
keyRotationSuppressed$: createKeyRotationSuppressed$(scope, jsSdkSession),
connectedTransports$: transports.connected$,
setAudioOutputDeviceId: async (deviceId) =>
backend.setAudioOutputDeviceId(deviceId),
sendData: async (topic, text) => backend.sendData(topic, text),
data$,
},
};
}
/** The host's backend, or LiveKit configured from the participation options. */
function createBackend(
scope: ObservableScope,
client: MatrixClient,
{ backend, mediaQuality }: RTCParticipationOptions,
context: MediaBackendContext,
): MediaBackend {
if (backend) return backend(scope, context);
return createLivekitBackend(scope, context, { client, mediaQuality });
}
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
function mediaKeys$(session: JsSdkRTCSession): Observable<MediaKey> {
return new Observable((subscriber) => {
const onKey = (
key: Uint8Array<ArrayBuffer>,
index: number,
_membership: CallMembershipIdentityParts,
participantId: string,
): void => subscriber.next({ participantId, index, key });
session.on(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
session.reemitEncryptionKeys();
return () => session.off(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
});
}
+71
View File
@@ -0,0 +1,71 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { type MatrixClient, type Room } from "matrix-js-sdk";
import { EventType } from "matrix-js-sdk/lib/@types/event";
import { type MatrixEvent } from "matrix-js-sdk/lib/models/event";
import {
type RoomState,
RoomStateEvent,
} from "matrix-js-sdk/lib/models/room-state";
import { createRTCSlot } from "./RTCSlot";
import { type RTCSlotOptions } from "./api";
import { MatrixRTCMode } from "./config";
import { E2eeType } from "./encryption";
import { mockEmitter, MockRTCSession, testScope } from "./utils/test";
const options: RTCSlotOptions = {
matrixRTCMode: MatrixRTCMode.Compatibility,
encryptionSystem: { kind: E2eeType.NONE },
};
function setup(): {
client: MatrixClient;
room: Room;
session: MockRTCSession;
} {
const room = {
...mockEmitter(),
roomId: "!room:example.org",
getMembersWithMembership: (): [] => [],
} as unknown as Room;
const session = new MockRTCSession(room);
const client = {
getUserId: (): string => "@alice:example.org",
getDeviceId: (): string => "AAAA",
matrixRTC: { getRoomSession: (): MockRTCSession => session },
} as unknown as MatrixClient;
return { client, room, session };
}
describe("createRTCSlot", () => {
describe("status$", () => {
it("is undefined while the room has no slot event", () => {
const { client, room } = setup();
const slot = createRTCSlot(testScope(), client, room, options);
expect(slot.status$.value).toBeUndefined();
});
it("follows the slot event", () => {
const { client, room, session } = setup();
session.rtcSlot = { status: "open" };
const slot = createRTCSlot(testScope(), client, room, options);
expect(slot.status$.value).toBe("open");
session.rtcSlot = { status: "closed" };
room.emit(
RoomStateEvent.Events,
{ getType: (): string => EventType.RTCSlot } as MatrixEvent,
{} as RoomState,
null,
);
expect(slot.status$.value).toBe("closed");
});
});
});
+195
View File
@@ -0,0 +1,195 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixClient, type Room } from "matrix-js-sdk";
import { EventType } from "matrix-js-sdk/lib/@types/event";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
type MatrixRTCSession as JsSdkRTCSession,
MatrixRTCSessionManager,
type SlotDescription,
} from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixEvent } from "matrix-js-sdk/lib/models/event";
import { RoomStateEvent } from "matrix-js-sdk/lib/models/room-state";
import {
BehaviorSubject,
filter,
fromEvent,
map,
of,
startWith,
switchMap,
} from "rxjs";
import { ObservableScope } from "./reactive/ObservableScope";
import { generateItems } from "./reactive/observable";
import {
type RTCMember,
type RTCParticipation,
type RTCParticipationOptions,
type RTCSlot,
type RTCSlotOptions,
} from "./api";
import { MatrixRTCError } from "./errors";
import {
createRTCParticipation,
type ParticipationInternals,
} from "./RTCParticipation";
import { createMemberships$ } from "./matrixrtc/JsRtcSessionBehaviors";
import {
createMatrixMemberMetadata$,
createRoomMembers$,
} from "./matrixrtc/MatrixMemberMetadata";
import {
createRTCMember,
type MemberContext,
membershipKeys,
} from "./matrixrtc/Members";
import { createTransportRegistry } from "./matrixrtc/Transports";
/**
* Takes the whole `MatrixClient` rather than a slice of it, and finds the
* MatrixRTC session itself. The js-sdk is the MatrixRTC implementation today;
* when the rust-rtc crate replaces it, the SDK has to bridge the crate to the
* js-sdk client, and only the SDK knows what that bridge needs. Holding the
* client keeps that change inside the SDK.
*/
export function createRTCSlot(
scope: ObservableScope,
client: MatrixClient,
room: Room,
options: RTCSlotOptions,
): RTCSlot {
const logger = rootLogger.getChild("[RTCSlot]");
const userId = client.getUserId();
const deviceId = client.getDeviceId();
if (!(userId && deviceId))
throw new MatrixRTCError("The client has to be logged in");
const { application = "m.call", id = "ROOM" } = options;
const jsSdkSession = sessionManager(
scope,
client,
{ application, id },
logger,
).getRoomSession(room);
const memberships$ = createMemberships$(scope, jsSdkSession);
const active$ = new BehaviorSubject<ParticipationInternals | null>(null);
const participationScopes = new Set<ObservableScope>();
scope.onEnd(() => {
for (const participationScope of participationScopes)
participationScope.end();
});
const transports = createTransportRegistry(
scope,
scope.behavior(
active$.pipe(switchMap((active) => active?.connections$ ?? of([]))),
),
);
const context: MemberContext = {
metadata: createMatrixMemberMetadata$(
scope,
scope.behavior(memberships$.pipe(map(({ value }) => value))),
createRoomMembers$(scope, room),
),
transports,
own: { userId, deviceId },
};
const members$ = scope.behavior<RTCMember[]>(
memberships$.pipe(
map(({ value }) => value),
generateItems(
"RTCSlot members",
function* (memberships) {
for (const membership of memberships)
yield { keys: membershipKeys(membership), data: membership };
},
(memberScope, membership$) =>
createRTCMember(memberScope, membership$, context),
),
),
);
// The status is undefined while the room has no slot event, and an explicit
// undefined is not an initial value to `behavior`, so it goes in the stream
const status$ = scope.behavior(
fromEvent(room, RoomStateEvent.Events, (event: MatrixEvent) => event).pipe(
filter((event) => event.getType() === EventType.RTCSlot),
map(() => slotStatus(jsSdkSession)),
startWith(slotStatus(jsSdkSession)),
),
);
const slot: RTCSlot = {
roomId: room.roomId,
application,
id,
status$,
members$,
participation$: scope.behavior(
active$.pipe(map((active) => active?.participation ?? null)),
),
join: (participationOptions: RTCParticipationOptions): RTCParticipation => {
if (active$.value !== null)
throw new MatrixRTCError(
"Already in this slot: leave the participation before joining again",
);
const participationScope = new ObservableScope();
participationScopes.add(participationScope);
const internals = createRTCParticipation(
participationScope,
{
client,
room,
slot,
jsSdkSession,
members$,
transports,
slotOptions: options,
logger,
},
participationOptions,
);
participationScope.onEnd(() => {
participationScopes.delete(participationScope);
if (active$.value === internals) active$.next(null);
});
active$.next(internals);
return internals.participation;
},
};
return slot;
}
function slotStatus(
session: Pick<JsSdkRTCSession, "getRtcSlot">,
): "open" | "closed" | undefined {
return session.getRtcSlot()?.status;
}
/**
* The js-sdk keeps one session manager per slot. The client comes with the
* manager for the default slot; any other slot gets a manager of its own,
* which lives as long as the scope.
*/
function sessionManager(
scope: ObservableScope,
client: MatrixClient,
{ application, id }: SlotDescription,
logger: Logger,
): MatrixRTCSessionManager {
if (application === "m.call" && id === "ROOM") return client.matrixRTC;
const manager = new MatrixRTCSessionManager(logger, client, {
application,
id,
});
manager.start();
scope.onEnd(() => manager.stop());
return manager;
}
+300
View File
@@ -0,0 +1,300 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* The public types of the SDK: the slot, the participation, their members and
* transports. What a member sends is in `media-api.ts`. `createRTCSlot` in
* `RTCSlot.ts` is the only way to obtain an implementation of them.
*/
import {
type RTCNotificationType,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { type Observable } from "rxjs";
import { type Behavior } from "./reactive/Behavior";
import {
type LocalAudioMediaTrack,
type LocalMemberMedia,
type LocalVideoMediaTrack,
type MediaSource,
type MemberMedia,
type PublishRequest,
} from "./media-api";
import { type MediaBackendFactory } from "./media-backend/api";
import { type EncryptionSystem } from "./encryption";
import {
type MatrixRTCMode,
type MediaQuality,
type SessionTimings,
} from "./config";
import { type MatrixRTCError } from "./errors";
export { MatrixRTCError } from "./errors";
// ---------------------------------------------------------------------------
// Slot
export interface RTCSlotOptions {
/** The application the slot belongs to, as named in the membership. Default `m.call`. */
application?: string;
/** The application's slot in the room. Default `ROOM`. */
id?: string;
/** How memberships and keys in this slot are written. Resolved by the host. */
matrixRTCMode: MatrixRTCMode;
encryptionSystem: EncryptionSystem;
}
/**
* The namespace in a room that memberships join into. It exists while the
* room does, with nobody in it too. The room has to be the one the client's
* sync loop maintains (`client.getRoom(roomId)`, once the join has synced):
* the detached copy `joinRoom` returns for a room joined just now never
* receives the state the members are read from.
*/
export interface RTCSlot {
readonly roomId: string;
readonly application: string;
readonly id: string;
/** From the slot state event; undefined while the room has none. */
status$: Behavior<"open" | "closed" | undefined>;
/**
* Everyone in the slot, one object per membership, kept for as long as the
* membership is. Our own membership is in here too, with `local: true`,
* once the room has seen it. No media: that is the participation's.
*/
members$: Behavior<RTCMember[]>;
/** The participation we are in, null between `leave()` and the next `join()`. */
participation$: Behavior<RTCParticipation | null>;
/**
* Joins the slot and returns the participation that lasts until its
* `leave()`. Throws while `participation$` is not null: a slot has one local
* member.
*/
join(options: RTCParticipationOptions): RTCParticipation; // Maybe MediaSession,
}
// ---------------------------------------------------------------------------
// Participation
export interface RTCParticipationOptions {
/**
* Published at the join; `publish` on the participation adds to it from then
* on. Kept as an option, rather than only the method, because the LiveKit
* backend reads the initial requests to build the room's default capture
* options before the first connection exists.
*/
publish: PublishRequest[];
/**
* LEGACY
*
* Conside already removing it. just skips code in js-sdk. But rust sdk will not have this feature.
* MSC4075 notification sent with the join. A parameter of the MatrixRTC
* join itself, so it is here even though it is named after calls; reacting
* to a notification (ringing, timeouts, declines) is the application's job.
*/
sendNotificationType?: RTCNotificationType;
/**
* Whatever the application wants to say about itself in the membership,
* under namespaced keys. Opaque to the SDK, apart from `m.call.intent`
* (`"audio"` or `"video"`), which the membership format carries as a
* field of its own.
*/
applicationData?: Record<string, unknown>;
/** Timings the host has configured; the defaults otherwise. */
timings?: Partial<SessionTimings>;
/** Limits on what is published; LiveKit's defaults otherwise. */
mediaQuality?: MediaQuality;
/**
* Use this transport instead of asking the homeserver. Any shape the
* backend serves: for LiveKit a `url`, a `livekit_service_url` or both.
*/
transport?: Transport;
/** Use this transport when the homeserver advertises none of the backend's type. */
fallbackTransport?: Transport;
/**
* What carries the media. LiveKit, configured from the options above, when
* left out; see `createLivekitBackend` for building one with other options.
*/
backend?: MediaBackendFactory;
}
/**
* The homeserver link that is down, in the order they depend on each other:
* without sync the membership is unknowable, and without a confirmed
* membership the delayed leave cannot be refreshed.
*/
export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
/**
* Why the participation is not connected: the first failing link, in the
* order they depend on each other. The media connection comes last because
* it is worth little while the Matrix side is down.
*/
export type DisconnectReason = MatrixDisconnectReason | "media";
export type ParticipationState =
/** Asking the homeserver for a transport, or preparing it. */
| { kind: "waitingForTransport" }
/** Transport known; joining the session and connecting the media for the first time. */
| { kind: "connecting" }
/** Membership joined and the local media connection up. */
| { kind: "connected" }
/** Was connected and one link is down. Back to `connected` or on to `failed`. */
| { kind: "reconnecting"; reason: DisconnectReason }
/**
* The membership or the media connection ended with an error. Terminal
* unless `left`. A failed publication is not fatal: the member can still
* receive, so it stays on the local member's media.
*/
| { kind: "failed"; error: MatrixRTCError }
/** `leave()` was called. Terminal. */
| { kind: "left" };
/** The span between our `join()` of a slot and our `leave()`. */
export interface RTCParticipation {
readonly slot: RTCSlot;
/** Sends the leave, tears the connections down and ends the participation. */
leave(): void;
state$: Behavior<ParticipationState>;
/** Null until our own membership has been seen in the room. */
localMember$: Behavior<LocalRTCMember | null>;
/**
* The slot's other members, each with the media this participation carries
* for it. A call view needs nothing but this and `localMember$` to render.
*/
remoteMembers$: Behavior<RemoteRTCMember[]>;
/**
* Publishes a source and resolves with its track, which also shows up in
* `localMember$.tracks$`. Works from the moment `join()` returns: before the
* transport is connected.
*/
publish(
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
/** Removes one of our publications; a screen share takes its audio with it. */
unpublish(source: MediaSource): Promise<void>;
/**
* Whether the slot has grown large enough that MatrixRTC has stopped
* rotating the media encryption key.
*/
keyRotationSuppressed$: Behavior<boolean>;
/** Transports the participation currently holds a live connection to. */
connectedTransports$: Behavior<TransportMetadata[]>;
/**
* Plays the members' audio on this device from now on. Rejects where the
* browser cannot switch to it. A host that routes audio itself, or leaves
* the browser's choice, never calls it.
*/
setAudioOutputDeviceId(deviceId: string): Promise<void>;
/**
* Sends a short text to every member on the local transport, over a
* reliable data channel beside the media. A packet holds about 15 KiB;
* anything larger belongs in a room event. Rejects while the local
* transport is not connected. Encrypted on the wire like the media, but
* not end to end with the media key.
*/
sendData(topic: string, text: string): Promise<void>;
/**
* What remote members sent with `sendData`, on every transport the
* participation is connected to. A message from an identity that is not a
* member is dropped, so a host only ever hears from attested members.
*/
data$: Observable<DataMessage>;
}
/** One `sendData` call as it arrives at a remote member. */
export interface DataMessage {
member: RemoteRTCMember;
topic: string;
text: string;
}
// ---------------------------------------------------------------------------
// Transports
/**
* One transport advertised in a membership. Transport independent: `type` and
* `id` are all the SDK needs; `raw` and `resolved$` are there for a
* backend-specific developer panel and for connection diagnostics.
*/
export interface TransportMetadata {
/** `"livekit"` today. */
type: string;
/** Stable key, unique per transport in the slot: the raw transport, serialised with sorted keys. */
id: string;
/** The transport object as it appears in the membership. */
raw: Transport;
/**
* What the backend had to fetch before it could connect. Undefined until the
* connection has resolved it, and again after the connection stops.
*/
resolved$: Behavior<ResolvedTransport | undefined>;
}
export type ResolvedTransport =
| {
type: "livekit";
/** The SFU websocket url, as opposed to the JWT service url in `raw`. */
url: string;
/** A secret: fit for a developer panel, never for a log line. */
token: string;
roomAlias: string;
identity: string;
}
| { type: string; [key: string]: unknown };
// ---------------------------------------------------------------------------
// Members
/** A membership in the slot, with what the SDK derives from it. No media. */
export interface RTCMember {
local: boolean;
/**
* The identity this member is known by on its transport for matching
* purposes, read off the membership: `${userId}:${deviceId}`. A stable key
* per membership and a debug label for a host; the SDK matches media
* against `backendIdentities`, not this.
*/
rtcBackendIdentity: string;
/**
* Every identity this member may publish under on its transport: the
* participant identity the SFU sees, and the sender of its data packets.
* One per authentication flow the member's transport allows, so a
* transport with both a `url` and a `livekit_service_url` lists the hashed
* and the legacy identity, and the backend matches whichever shows up.
* Opaque to a host.
*/
backendIdentities: readonly string[];
userId: string;
deviceId: string;
/** The membership's own id; what the Matrix 2.0 identity is derived from. */
memberId: string;
/** Which transport this member is on; undefined when the membership has none. */
transport$: Behavior<TransportMetadata | undefined>;
/** What the member's application says about itself, e.g. `m.call.intent`. */
applicationData$: Behavior<Record<string, unknown>>;
displayName$: Behavior<string>;
avatarUrl$: Behavior<string | undefined>;
}
export interface RemoteRTCMember extends RTCMember, MemberMedia {
local: false;
}
/** Our member: the same as a remote one, with tracks that carry the controls. */
export interface LocalRTCMember extends RTCMember, LocalMemberMedia {
local: true;
}
+101
View File
@@ -0,0 +1,101 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* The MatrixRTC mode determines how the client interacts with the MatrixRTC
* backend and other participants. Resolved by the host.
*/
export enum MatrixRTCMode {
/** Multi-SFU transport, membership state events. */
Compatibility = "compatibility",
/**
* Multi-SFU transport with sticky membership events and a uuid member id.
* The mode decides how the membership is written, not how the media
* transport is authenticated with: that follows the transport's shape and
* our role on it.
*/
Matrix_2_0 = "matrix_2_0",
}
export interface DelayedLeaveTimings {
/** How long (in milliseconds) the homeserver waits before sending the delayed leave. */
delay_ms: number;
/** How often (in milliseconds) the client restarts the delayed leave. */
restart_ms?: number;
/** How long (in milliseconds) a restart may take before it counts as failed. */
restart_timeout_ms?: number;
}
/**
* The session's timings. A host that has them in its configuration passes
* them in; these are the defaults a MatrixRTC backend is expected to accept.
*/
export interface SessionTimings {
/** How long the sync may be down before the client counts as disconnected. */
syncDisconnectGracePeriodMs: number;
/** How long to wait before retrying after a network error on any request. */
networkErrorRetryMs: number;
/** How long to wait before rotating the media key when someone leaves. */
waitForKeyRotationMs?: number;
/** When the membership expires if the client stops renewing it. */
membershipEventExpiryMs?: number;
/** The session size at which the media key stops being rotated. */
keyRotationParticipantLimit?: number;
delayedLeave: DelayedLeaveTimings;
/**
* The timings used where the SFU restarts the delayed leave on the
* client's behalf, which lets the client stop renewing it itself.
*/
delegatedDelayedLeave: DelayedLeaveTimings;
}
export const defaultSessionTimings: SessionTimings = {
syncDisconnectGracePeriodMs: 10_000,
networkErrorRetryMs: 1_000,
delayedLeave: { delay_ms: 18_000, restart_ms: 4_000 },
delegatedDelayedLeave: { delay_ms: 3_600_000, restart_ms: 300_000 },
};
/** Media quality settings a host may pass; everything is optional. */
export interface MediaQuality {
/** Video codec preference. The server must also have the codec enabled. Default `vp8`. */
video_codec?: "vp8" | "vp9" | "h264" | "av1";
video?: {
/** Max resolution height in pixels. Default 720. */
max_resolution?: number;
/** Max bitrate in bits per second. Default 1700000. */
max_bitrate?: number;
/** Max framerate. Default 30. */
max_framerate?: number;
/** Simulcast layers, lowest quality first. Default 180p and 360p. */
simulcast_layers?: Array<{ height: number; bitrate: number }>;
};
screen_share?: {
/** Max resolution height in pixels. Default 1080. */
max_resolution?: number;
/** Max bitrate in bits per second. Default 5000000. */
max_bitrate?: number;
/** Max framerate. Default 30. */
max_framerate?: number;
/** Simulcast layers, lowest quality first. Without them LiveKit adds one layer at half resolution. */
simulcast_layers?: Array<{
height: number;
bitrate: number;
framerate?: number;
}>;
};
}
export const defaultMediaQuality = {
video_codec: "vp8",
video: { max_resolution: 720, max_bitrate: 1_700_000, max_framerate: 30 },
screen_share: {
max_resolution: 1080,
max_bitrate: 5_000_000,
max_framerate: 30,
},
} as const satisfies MediaQuality;
+27
View File
@@ -0,0 +1,27 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
export enum E2eeType {
NONE = 0,
PER_PARTICIPANT = 1,
SHARED_KEY = 2,
}
export interface Unencrypted {
kind: E2eeType.NONE;
}
export interface SharedSecret {
kind: E2eeType.SHARED_KEY;
secret: string;
}
export interface PerParticipantE2EE {
kind: E2eeType.PER_PARTICIPANT;
}
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
+169
View File
@@ -0,0 +1,169 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ConnectionError } from "livekit-client";
export enum ErrorCode {
/** No MatrixRTC transport provided by the homeserver and no fallback configured. */
MISSING_MATRIX_RTC_TRANSPORT = "MISSING_MATRIX_RTC_TRANSPORT",
CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR",
INTERNAL_MEMBERSHIP_MANAGER = "INTERNAL_MEMBERSHIP_MANAGER",
FAILED_TO_START_LIVEKIT = "FAILED_TO_START_LIVEKIT",
/** LiveKit indicates that the server has hit its track limits. */
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
OPEN_ID_ERROR = "OPEN_ID_ERROR",
NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE",
SFU_ERROR = "SFU_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
export enum ErrorCategory {
/** Calling is not supported, the server is misconfigured (JWT service missing, no MSC support). */
CONFIGURATION_ISSUE = "CONFIGURATION_ISSUE",
NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY",
CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION",
UNKNOWN = "UNKNOWN",
SYSTEM_FAILURE = "SYSTEM_FAILURE",
}
/**
* An error raised by the client. `code` says what went wrong in a form a host
* can translate; `cause` holds the underlying error where there is one.
*/
export class MatrixRTCError extends Error {
public constructor(
message: string,
public readonly code: ErrorCode = ErrorCode.UNKNOWN_ERROR,
public readonly category: ErrorCategory = ErrorCategory.UNKNOWN,
options?: ErrorOptions,
) {
super(message, options);
this.name = "MatrixRTCError";
}
}
/** Wraps whatever a backend threw into the SDK's error type, keeping it as the cause. */
export function toMatrixRTCError(error: unknown): MatrixRTCError {
if (error instanceof MatrixRTCError) return error;
if (error instanceof Error)
return new MatrixRTCError(
error.message || error.name,
ErrorCode.UNKNOWN_ERROR,
ErrorCategory.UNKNOWN,
{ cause: error },
);
return new MatrixRTCError(String(error));
}
export class MatrixRTCTransportMissingError extends MatrixRTCError {
public constructor(public readonly domain: string) {
super(
`The homeserver ${domain} advertises no MatrixRTC transport`,
ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
ErrorCategory.CONFIGURATION_ISSUE,
);
}
}
export class MembershipManagerError extends MatrixRTCError {
public constructor(cause: Error) {
super(
`The membership manager stopped: ${cause.message}`,
ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
ErrorCategory.SYSTEM_FAILURE,
{ cause },
);
}
}
export class UnknownRTCError extends MatrixRTCError {
public constructor(cause: Error) {
super(
cause.message || "Unknown error",
ErrorCode.UNKNOWN_ERROR,
ErrorCategory.UNKNOWN,
{
cause,
},
);
}
}
export class FailToGetOpenIdToken extends MatrixRTCError {
public constructor(cause: Error) {
super(
cause instanceof MatrixRTCError
? cause.message
: `Could not authenticate with the transport: ${cause.message}`,
ErrorCode.OPEN_ID_ERROR,
ErrorCategory.CONFIGURATION_ISSUE,
{ cause },
);
}
}
export class NoMatrix2AuthorizationService extends MatrixRTCError {
public constructor(cause: Error) {
super(
"The transport's authorization service does not support Matrix 2.0",
ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE,
ErrorCategory.CONFIGURATION_ISSUE,
{ cause },
);
}
}
export class FailToStartLivekitConnection extends MatrixRTCError {
public constructor(detail?: string) {
super(
`Failed to start the media connection${detail ? `: ${detail}` : ""}`,
ErrorCode.FAILED_TO_START_LIVEKIT,
ErrorCategory.NETWORK_CONNECTIVITY,
);
}
}
export class InsufficientCapacityError extends MatrixRTCError {
public constructor() {
super(
"The media server has no capacity left",
ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
ErrorCategory.UNKNOWN,
);
}
}
export class SFURoomCreationRestrictedError extends MatrixRTCError {
public constructor() {
super(
"The media server did not let this client create the room",
ErrorCode.SFU_ERROR,
ErrorCategory.CONFIGURATION_ISSUE,
);
}
}
export class PeerConnectionTimeoutError extends MatrixRTCError {
public constructor() {
super(
"Timed out establishing the media connection",
ErrorCode.SFU_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
);
}
}
export class LivekitConnectionError extends MatrixRTCError {
public constructor(cause: ConnectionError) {
super(
`Could not connect to the media server: ${cause.message}`,
ErrorCode.SFU_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
{ cause },
);
}
}
@@ -11,9 +11,9 @@ import { EventEmitter } from "events";
import { ClientEvent, SyncState } from "matrix-js-sdk";
import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
import { ObservableScope } from "../../ObservableScope";
import { ObservableScope } from "../reactive/ObservableScope";
import { createHomeserverConnected$ } from "./HomeserverConnected";
import { testScope, withTestScheduler } from "../../../utils/test";
import { testScope, withTestScheduler } from "../utils/test";
/**
* Minimal stub of a Matrix client sufficient for our tests:
@@ -100,13 +100,13 @@ describe("createHomeserverConnected$", () => {
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
it("reports syncing reason when sync state is not Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
expect(hsConnected.disconnectReason$.value).toEqual("sync");
});
it("reports membership reason when sync is Syncing but membership is not Connected", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
expect(hsConnected.disconnectReason$.value).toEqual("membership");
});
it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
@@ -116,17 +116,17 @@ describe("createHomeserverConnected$", () => {
// Indicate probable leave before connection
session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
});
it("becomes null (connected) only when all three conditions are satisfied", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// 1. Sync loop connected
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toEqual([false, "membership"]); // not yet membership connected
expect(hsConnected.disconnectReason$.value).toEqual("membership"); // not yet membership connected
// 2. Membership connected
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]); // probablyLeft is false
expect(hsConnected.disconnectReason$.value).toEqual(null); // probablyLeft is false
});
it("returns syncing reason when sync loop leaves Syncing", () => {
@@ -134,76 +134,76 @@ describe("createHomeserverConnected$", () => {
// Reach connected state
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]);
expect(hsConnected.disconnectReason$.value).toEqual(null);
// Sync loop error => should report syncing reason
client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
expect(hsConnected.disconnectReason$.value).toEqual("sync");
});
it("returns membershipConnected reason when membership status becomes disconnected", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]);
expect(hsConnected.disconnectReason$.value).toEqual(null);
session.setMembershipStatus(Status.Disconnected);
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
expect(hsConnected.disconnectReason$.value).toEqual("membership");
});
it("returns certainlyConnected reason when ProbablyLeft is emitted", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]);
expect(hsConnected.disconnectReason$.value).toEqual(null);
session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
});
it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]);
expect(hsConnected.disconnectReason$.value).toEqual(null);
session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
// Simulate clearing the flag (in realistic scenario membership manager would update)
session.setProbablyLeft(false);
expect(hsConnected.combined$.value).toEqual([true, null]);
expect(hsConnected.disconnectReason$.value).toEqual(null);
});
it("composite sequence reflects each individual failure reason", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Initially: sync error + membership disconnected → syncing wins (highest priority)
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
expect(hsConnected.disconnectReason$.value).toEqual("sync");
// Fix sync only → membershipConnected is now the blocker
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
expect(hsConnected.disconnectReason$.value).toEqual("membership");
// Fix membership → all conditions satisfied
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]);
expect(hsConnected.disconnectReason$.value).toEqual(null);
// Introduce probablyLeft → certainlyConnected
session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
// Restore notProbablyLeft → connected again
session.setProbablyLeft(false);
expect(hsConnected.combined$.value).toEqual([true, null]);
expect(hsConnected.disconnectReason$.value).toEqual(null);
// Drop sync → syncing reason
client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
expect(hsConnected.disconnectReason$.value).toEqual("sync");
});
});
describe("createHomeserverConnected$ - combined$ reason values", () => {
describe("createHomeserverConnected$ - disconnectReason$ reason values", () => {
let scope: ObservableScope;
let client: MockMatrixClient;
let session: MockMatrixRTCSession;
@@ -222,57 +222,87 @@ describe("createHomeserverConnected$ - combined$ reason values", () => {
scope.end();
});
it("is [true, null] when all three conditions are satisfied", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
it("is null when all three conditions are satisfied", () => {
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(combined$.value).toEqual([true, null]);
expect(disconnectReason$.value).toEqual(null);
});
it("reports syncing when sync loop is not Syncing", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
// client starts with SyncState.Error, membership also disconnected
expect(combined$.value).toEqual([false, "sync"]);
expect(disconnectReason$.value).toEqual("sync");
});
it("reports membershipConnected when sync is fine but membership is not Connected", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
client.setSyncState(SyncState.Syncing);
// session still Status.Disconnected
expect(combined$.value).toEqual([false, "membership"]);
expect(disconnectReason$.value).toEqual("membership");
});
it("reports certainlyConnected when probablyLeft is true", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
session.setProbablyLeft(true);
expect(combined$.value).toEqual([false, "probablyLeft"]);
expect(disconnectReason$.value).toEqual("probablyLeft");
});
it("prioritises syncing over membershipConnected when both fail", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
// Both sync (Error) and membership (Disconnected) are failing
expect(combined$.value).toEqual([false, "sync"]);
expect(disconnectReason$.value).toEqual("sync");
});
it("updates reason as conditions change", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
// Initially: syncing fails
expect(combined$.value).toEqual([false, "sync"]);
expect(disconnectReason$.value).toEqual("sync");
// Fix sync → membershipConnected is now the blocker
client.setSyncState(SyncState.Syncing);
expect(combined$.value).toEqual([false, "membership"]);
expect(disconnectReason$.value).toEqual("membership");
// Fix membership → probablyLeft makes certainlyConnected fail
session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected);
expect(combined$.value).toEqual([false, "probablyLeft"]);
expect(disconnectReason$.value).toEqual("probablyLeft");
// Clear probablyLeft → all conditions satisfied
session.setProbablyLeft(false);
expect(combined$.value).toEqual([true, null]);
expect(disconnectReason$.value).toEqual(null);
});
});
@@ -303,10 +333,13 @@ describe("createHomeserverConnected$ - Grace Period", () => {
session,
GRACE_PERIOD,
);
expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, {
y: [true, null],
n: [false, "sync"],
});
expectObservable(hsConnected.disconnectReason$).toBe(
expectedConnectedMarbles,
{
y: null,
n: "sync",
},
);
});
}
@@ -26,27 +26,28 @@ import {
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { Config } from "../../../config/Config";
import { type ObservableScope } from "../../ObservableScope";
import { type Behavior } from "../../Behavior";
import { type NodeStyleEventEmitter } from "../../../utils/test";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type Behavior } from "../reactive/Behavior";
import { defaultSessionTimings } from "../config";
import { type MatrixDisconnectReason } from "../api";
export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft";
type NodeEventHandler = (...args: unknown[]) => void;
interface NodeStyleEventEmitter {
addListener(eventName: string | symbol, handler: NodeEventHandler): this;
removeListener(eventName: string | symbol, handler: NodeEventHandler): this;
}
export interface HomeserverConnected {
/**
* Emits `[true, null]` when the homeserver connection is healthy, or
* `[false, reason]` when one of the three sub-conditions fails.
*/
combined$: Behavior<[boolean, HomeserverDisconnectReason | null]>;
rtsSession$: Behavior<Status>;
/** Null while the homeserver connection is healthy, the first failing link otherwise. */
disconnectReason$: Behavior<MatrixDisconnectReason | null>;
}
/**
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
* for the purposes of a MatrixRTC session.
*
* `combined$` emits `null` when all conditions are satisfied, or the first failing
* `disconnectReason$` emits `null` when all conditions are satisfied, or the first failing
* reason (priority: syncing > membershipConnected > certainlyConnected):
* 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync"
* 2. membershipStatus !== Status.Connected → "membership"
@@ -67,7 +68,8 @@ export function createHomeserverConnected$(
): HomeserverConnected {
const logger = rootLogger.getChild("[HomeserverConnected]");
// Get grace period from parameter or config (default 10000ms)
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;
const graceMs =
gracePeriodMs ?? defaultSessionTimings.syncDisconnectGracePeriodMs;
const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
@@ -111,26 +113,19 @@ export function createHomeserverConnected$(
map(() => matrixRTCSession.probablyLeft !== true),
);
const combined$ = scope.behavior(
const disconnectReason$ = scope.behavior(
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
map(
([syncing, membership, certainly]): [
boolean,
HomeserverDisconnectReason | null,
] => {
if (!syncing) return [false, "sync"];
if (!membership) return [false, "membership"];
if (!certainly) return [false, "probablyLeft"];
return [true, null];
},
),
tap(([connected, reason]) => {
logger.info(
`Homeserver connected update: ${connected ? "connected" : reason}`,
);
map(([syncing, membership, certainly]): MatrixDisconnectReason | null => {
if (!syncing) return "sync";
if (!membership) return "membership";
if (!certainly) return "probablyLeft";
return null;
}),
tap((reason) => {
logger.info(`Homeserver connected update: ${reason ?? "connected"}`);
}),
),
);
return { combined$, rtsSession$ };
return { disconnectReason$ };
}
@@ -9,10 +9,10 @@ import { describe, expect, it, vi } from "vitest";
import { MatrixRTCSessionEvent } from "matrix-js-sdk/lib/matrixrtc";
import { EventEmitter } from "events";
import { createKeyRotationSuppressed$ } from "./SessionBehaviors";
import { createKeyRotationSuppressed$ } from "./JsRtcSessionBehaviors";
import { testScope } from "../utils/test";
describe("SessionBehaviors", () => {
describe("JsRtcSessionBehaviors", () => {
describe("createKeyRotationSuppressed$", () => {
it("emits initial value from isKeyRotationSuppressed and updates when KeyRotationSuppressedChanged event is emitted", () => {
const scope = testScope();
@@ -0,0 +1,53 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type CallMembership,
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { fromEvent } from "rxjs";
import {
Epoch,
trackEpoch,
type ObservableScope,
} from "../reactive/ObservableScope";
import { type Behavior } from "../reactive/Behavior";
export const createMemberships$ = (
scope: ObservableScope,
matrixRTCSession: MatrixRTCSession,
): Behavior<Epoch<CallMembership[]>> => {
return scope.behavior(
fromEvent(
matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
(_, memberships: CallMembership[]) => memberships,
).pipe(trackEpoch()),
new Epoch(matrixRTCSession.memberships),
);
};
/**
* Whether the session has grown large enough that MatrixRTC has stopped rotating the media
* encryption key. While this is true the key in use is still shared with new joiners, but no new
* key is generated when someone joins or leaves.
*/
export const createKeyRotationSuppressed$ = (
scope: ObservableScope,
matrixRTCSession: MatrixRTCSession,
): Behavior<boolean> => {
return scope.behavior(
fromEvent(
matrixRTCSession,
MatrixRTCSessionEvent.KeyRotationSuppressedChanged,
(suppressed: boolean) => suppressed,
),
matrixRTCSession.isKeyRotationSuppressed,
);
};
+168
View File
@@ -0,0 +1,168 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject, type Observable, Subject, throwError } from "rxjs";
import { defaultSessionTimings } from "../config";
import { MatrixRTCError } from "../errors";
import { type HomeserverConnected } from "./HomeserverConnected";
import {
createLocalMembership$,
type LocalMembership,
type PreparedTransport,
} from "./LocalMembership";
import { flushPromises, testScope } from "../utils/test";
const transport: Transport = { type: "livekit", livekit_service_url: "u" };
describe("createLocalMembership$", () => {
it("waits for the transport, joins, and leaves on leave()", async () => {
const { membership, homeserver, prepared$ } = setup();
expect(membership.state$.value).toEqual({ kind: "waitingForTransport" });
homeserver.disconnectReason$.next("membership");
prepared$.next({ transport, canDelegateDelayedLeave: false });
expect(membership.state$.value).toEqual({ kind: "joining" });
homeserver.disconnectReason$.next(null);
expect(membership.state$.value).toEqual({ kind: "joined" });
membership.leave();
await flushPromises();
expect(membership.state$.value).toEqual({ kind: "left" });
});
it("reconnects only after it was joined once", () => {
const { membership, homeserver, prepared$ } = setup();
prepared$.next({ transport, canDelegateDelayedLeave: false });
expect(membership.state$.value).toEqual({ kind: "joined" });
homeserver.disconnectReason$.next("sync");
expect(membership.state$.value).toEqual({
kind: "reconnecting",
reason: "sync",
});
homeserver.disconnectReason$.next("probablyLeft");
expect(membership.state$.value).toEqual({
kind: "reconnecting",
reason: "probablyLeft",
});
homeserver.disconnectReason$.next(null);
expect(membership.state$.value).toEqual({ kind: "joined" });
});
it("joins with the delegated timings and hands over each delay id where the backend can", async () => {
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
prepared$.next({ transport, canDelegateDelayedLeave: true });
await flushPromises();
expect(joinMatrixRTC).toHaveBeenCalledWith(
transport,
defaultSessionTimings.delegatedDelayedLeave,
);
delayId$.next("delay-1");
await flushPromises();
expect(delegate).toHaveBeenCalledWith("delay-1");
delayId$.next("delay-2");
await flushPromises();
expect(delegate).toHaveBeenLastCalledWith("delay-2");
});
it("never hands the leave over where the backend cannot take it", async () => {
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
prepared$.next({ transport, canDelegateDelayedLeave: false });
delayId$.next("delay-1");
await flushPromises();
expect(joinMatrixRTC).toHaveBeenCalledWith(
transport,
defaultSessionTimings.delayedLeave,
);
expect(delegate).not.toHaveBeenCalled();
});
it("reports a transport that could not be prepared as failed", () => {
const error = new MatrixRTCError("no token");
const { membership } = setup(throwError(() => error));
expect(membership.state$.value).toEqual({ kind: "failed", error });
});
it("reports the membership manager giving up as failed, and stays failed", () => {
const { membership, homeserver, prepared$, membershipManagerError$ } =
setup();
prepared$.next({ transport, canDelegateDelayedLeave: false });
membershipManagerError$.next(new Error("gave up"));
const state = membership.state$.value;
expect(state.kind).toBe("failed");
homeserver.disconnectReason$.next("sync");
homeserver.disconnectReason$.next(null);
expect(membership.state$.value).toBe(state);
});
it("is left after a failure once leave() is called", () => {
const { membership } = setup(throwError(() => new MatrixRTCError("x")));
membership.leave();
expect(membership.state$.value).toEqual({ kind: "left" });
});
});
function setup(preparedTransportWithErrors$?: Observable<PreparedTransport>): {
membership: LocalMembership;
homeserver: FakeHomeserverConnected;
prepared$: Subject<PreparedTransport>;
delayId$: BehaviorSubject<string | null>;
membershipManagerError$: Subject<unknown>;
joinMatrixRTC: ReturnType<typeof vi.fn>;
delegate: ReturnType<typeof vi.fn>;
} {
const prepared$ = new Subject<PreparedTransport>();
const homeserver = fakeHomeserverConnected();
const delayId$ = new BehaviorSubject<string | null>(null);
const membershipManagerError$ = new Subject<unknown>();
const joinMatrixRTC = vi.fn();
const delegate = vi.fn(async () => Promise.resolve());
const membership = createLocalMembership$({
scope: testScope(),
delegateDelayedLeave: delegate,
preparedTransport$: preparedTransportWithErrors$ ?? prepared$,
homeserverConnected: homeserver,
joinMatrixRTC,
membershipManagerError$,
matrixRTCSession: {
leave: vi.fn(async () => Promise.resolve(true)),
},
delayId$,
timings: defaultSessionTimings,
logger,
});
return {
membership,
homeserver,
prepared$,
delayId$,
membershipManagerError$,
joinMatrixRTC,
delegate,
};
}
type FakeHomeserverConnected = {
disconnectReason$: BehaviorSubject<
HomeserverConnected["disconnectReason$"]["value"]
>;
};
function fakeHomeserverConnected(): FakeHomeserverConnected {
return {
disconnectReason$: new BehaviorSubject<
HomeserverConnected["disconnectReason$"]["value"]
>(null),
};
}
+210
View File
@@ -0,0 +1,210 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
type MatrixRTCSession as JsSdkRTCSession,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import {
BehaviorSubject,
catchError,
combineLatest,
distinctUntilChanged,
NEVER,
type Observable,
scan,
} from "rxjs";
import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type DelayedLeaveTimings, type SessionTimings } from "../config";
import { type MatrixDisconnectReason } from "../api";
import {
type MatrixRTCError,
MembershipManagerError,
toMatrixRTCError,
} from "../errors";
import {
type MediaBackend,
type TransportCapabilities,
} from "../media-backend/api";
import { type HomeserverConnected } from "./HomeserverConnected";
export type MembershipState =
/** Asking the homeserver for a transport, or preparing it. */
| { kind: "waitingForTransport" }
/** Transport known; sending the membership for the first time. */
| { kind: "joining" }
/** Syncing, membership confirmed by the server, delayed leave still being refreshed. */
| { kind: "joined" }
/** Was joined and one homeserver link is down; the membership manager is retrying. */
| { kind: "reconnecting"; reason: MatrixDisconnectReason }
/** A transport, join or membership manager error ended the membership. Terminal unless `left`. */
| { kind: "failed"; error: MatrixRTCError }
/** `leave()` was called. Terminal. */
| { kind: "left" };
/** The local transport, found and prepared, with what the join needs to know about it. */
export interface PreparedTransport extends TransportCapabilities {
transport: Transport;
}
interface Props {
scope: ObservableScope;
delegateDelayedLeave: MediaBackend["delegateDelayedLeave"];
preparedTransport$: Observable<PreparedTransport>;
homeserverConnected: HomeserverConnected;
joinMatrixRTC: (
transport: Transport,
delayedLeave: DelayedLeaveTimings,
) => void;
/** The membership manager giving up on keeping the membership alive. */
membershipManagerError$: Observable<unknown>;
matrixRTCSession: Pick<JsSdkRTCSession, "leave">;
/** The id of the delayed leave event, for the backend to take over. */
delayId$: Behavior<string | null>;
timings: SessionTimings;
logger: Logger;
}
export interface LocalMembership {
/** Sends the leave. Final: there is no joining again. */
leave: () => void;
state$: Behavior<MembershipState>;
}
/**
* The Matrix side of the local member: waits for the transport, enters the
* MatrixRTC session, keeps the delayed leave delegated, and leaves on
* `leave()`. Knows nothing about the media.
*/
export function createLocalMembership$({
scope,
delegateDelayedLeave,
preparedTransport$: preparedTransportWithErrors$,
homeserverConnected,
joinMatrixRTC,
membershipManagerError$,
matrixRTCSession,
delayId$,
timings,
logger: parentLogger,
}: Props): LocalMembership {
const logger = parentLogger.getChild("[LocalMembership]");
const transportError$ = new BehaviorSubject<MatrixRTCError | null>(null);
const transport$ = scope.behavior<PreparedTransport | null>(
preparedTransportWithErrors$.pipe(
catchError((e: unknown) => {
transportError$.next(toMatrixRTCError(e));
return NEVER;
}),
),
null,
);
const joined$ = new BehaviorSubject(true);
const matrixError$ = new BehaviorSubject<MatrixRTCError | null>(null);
scope.reconcile(
scope.behavior(combineLatest([transport$, joined$])),
async ([prepared, joined]) => {
if (prepared === null || !joined) return;
try {
joinMatrixRTC(
prepared.transport,
prepared.canDelegateDelayedLeave
? timings.delegatedDelayedLeave
: timings.delayedLeave,
);
} catch (e) {
logger.error("Failed to enter the session", e);
if (matrixError$.value === null) matrixError$.next(toMatrixRTCError(e));
}
return Promise.resolve(async (): Promise<void> => {
try {
await matrixRTCSession.leave(1000);
} catch (e) {
logger.error("Failed to leave the session", e);
}
});
},
);
scope.reconcile(
scope.behavior(combineLatest([transport$, delayId$])),
async ([prepared, delayId]) => {
if (!prepared?.canDelegateDelayedLeave || delayId === null) return;
try {
await delegateDelayedLeave(delayId);
} catch (e) {
logger.error("Failed to delegate the leave", e);
}
},
);
membershipManagerError$.pipe(scope.bind()).subscribe((e) => {
logger.error("The membership manager stopped", e);
if (matrixError$.value === null)
matrixError$.next(
new MembershipManagerError(
e instanceof Error ? e : new Error(String(e)),
),
);
});
const state$ = scope.behavior<MembershipState>(
combineLatest([
joined$,
transportError$,
matrixError$,
transport$,
homeserverConnected.disconnectReason$,
]).pipe(
scan(
(previous, [joined, transportError, matrixError, transport, reason]) =>
membershipState(previous, {
joined,
error: transportError ?? matrixError,
hasTransport: transport !== null,
reason,
}),
{ kind: "waitingForTransport" } as MembershipState,
),
distinctUntilChanged(deepCompare),
),
);
return {
leave: () => joined$.next(false),
state$,
};
}
interface MembershipInputs {
joined: boolean;
error: MatrixRTCError | null;
hasTransport: boolean;
reason: MatrixDisconnectReason | null;
}
/** The next state, in priority order; `left` and `failed` are absorbing. */
function membershipState(
previous: MembershipState,
{ joined, error, hasTransport, reason }: MembershipInputs,
): MembershipState {
if (!joined) return { kind: "left" };
if (previous.kind === "failed") return previous;
if (error !== null) return { kind: "failed", error };
if (!hasTransport) return { kind: "waitingForTransport" };
if (reason === null) return { kind: "joined" };
const wasJoined =
previous.kind === "joined" || previous.kind === "reconnecting";
return wasJoined ? { kind: "reconnecting", reason } : { kind: "joining" };
}
+113
View File
@@ -0,0 +1,113 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/lib/logger";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { describe, expect, it, vi } from "vitest";
import { MatrixRTCTransportMissingError } from "../errors";
import { discoverLocalTransport } from "./LocalTransport";
const homeserverTransport: Transport = {
type: "livekit",
livekit_service_url: "https://homeserver.example.org",
};
const hostTransport: Transport = {
type: "livekit",
livekit_service_url: "https://host.example.org",
};
const bobsTransport: Transport = {
type: "livekit",
url: "wss://sfu.bob.com",
server_name: "bob.com",
};
function client(
advertised: Transport[],
): Parameters<typeof discoverLocalTransport>[0] {
return {
getDomain: () => "example.org",
_unstable_getRTCTransports: vi.fn().mockResolvedValue(advertised),
};
}
function fromBackends(
transport: Transport | null = null,
): ReturnType<typeof vi.fn<() => Promise<Transport | null>>> {
return vi.fn(async () => Promise.resolve(transport));
}
describe("discoverLocalTransport", () => {
it("takes the transport the host names without asking the homeserver", async () => {
const c = client([homeserverTransport]);
await expect(
discoverLocalTransport(
c,
"livekit",
{ transport: hostTransport },
fromBackends(),
logger,
),
).resolves.toBe(hostTransport);
expect(c._unstable_getRTCTransports).not.toHaveBeenCalled();
});
it("prefers the homeserver's transport over the host's fallback", async () => {
await expect(
discoverLocalTransport(
client([homeserverTransport]),
"livekit",
{ fallbackTransport: hostTransport },
fromBackends(),
logger,
),
).resolves.toEqual(homeserverTransport);
});
it("falls back to the host's transport where the homeserver advertises none of the type", async () => {
await expect(
discoverLocalTransport(
client([{ type: "not_livekit" }]),
"livekit",
{ fallbackTransport: hostTransport },
fromBackends(),
logger,
),
).resolves.toEqual(hostTransport);
});
it("prefers a local transport over the backends' without asking them", async () => {
const backends = fromBackends(bobsTransport);
await expect(
discoverLocalTransport(
client([homeserverTransport]),
"livekit",
{},
backends,
logger,
),
).resolves.toEqual(homeserverTransport);
expect(backends).not.toHaveBeenCalled();
});
it("fails naming the homeserver where there is no transport at all", async () => {
const promise = discoverLocalTransport(
client([]),
"livekit",
{},
fromBackends(),
logger,
);
await expect(promise).rejects.toBeInstanceOf(
MatrixRTCTransportMissingError,
);
await expect(promise).rejects.toHaveProperty("domain", "example.org");
});
});
+46
View File
@@ -0,0 +1,46 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixClient } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { type RTCParticipationOptions } from "../api";
import { MatrixRTCTransportMissingError } from "../errors";
import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
/**
* The transport the local member advertises: the one the host names, else the
* homeserver's first of the type the backend serves, else the host's fallback,
* else the first the backends can provide, which are asked only then.
*
* @throws {MatrixRTCTransportMissingError} where none of these exists.
*/
export async function discoverLocalTransport(
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
transportType: string,
{
transport: override,
fallbackTransport,
}: Pick<RTCParticipationOptions, "transport" | "fallbackTransport">,
transportFromBackends: () => Promise<Transport | null>,
logger: Logger,
): Promise<Transport> {
const transport =
override ??
(await new TransportAutoDiscovery({
client,
transportType,
fallbackTransport,
logger,
}).discoverPreferredTransport()) ??
(await transportFromBackends()) ??
null;
if (transport === null)
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
return transport;
}
@@ -15,13 +15,13 @@ import {
import EventEmitter from "events";
import { it } from "vitest";
import { ObservableScope } from "../../ObservableScope.ts";
import { ObservableScope } from "../reactive/ObservableScope";
import type { Room as MatrixRoom } from "matrix-js-sdk/lib/models/room";
import {
mockRtcMembership,
mockMatrixRoomMember,
withTestScheduler,
} from "../../../utils/test.ts";
} from "../utils/test";
import {
createMatrixMemberMetadata$,
createRoomMembers$,
@@ -15,12 +15,9 @@ import {
} from "matrix-js-sdk/lib/matrix";
// eslint-disable-next-line rxjs/no-internal
import { type ObservableScope } from "../../ObservableScope";
import {
calculateDisplayName,
shouldDisambiguate,
} from "../../../utils/displayname";
import { type Behavior } from "../../Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
import { type Behavior } from "../reactive/Behavior";
export type RoomMemberMap = Map<
string,
+95
View File
@@ -0,0 +1,95 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { distinctUntilChanged, map } from "rxjs";
import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type createMatrixMemberMetadata$ } from "./MatrixMemberMetadata";
import {
type LocalRTCMember,
type RemoteRTCMember,
type RTCMember,
} from "../api";
import { type LocalMemberMedia, type MemberMedia } from "../media-api";
import { type TransportRegistry } from "./Transports";
/** What every member is built from, besides its own membership. */
export interface MemberContext {
metadata: ReturnType<typeof createMatrixMemberMetadata$>;
transports: TransportRegistry;
/** Whose memberships are `local`. */
own: { userId: string; deviceId: string };
}
/** Everything that tells one membership from another, for keying items. */
export function membershipKeys(
membership: CallMembership,
): [string, string, string, string] {
return [
membership.userId,
membership.deviceId,
membership.memberId,
membership.rtcBackendIdentity,
];
}
/** The slot's view of a membership: identity, room state and transport, no media. */
export function createRTCMember(
scope: ObservableScope,
membership$: Behavior<CallMembership>,
{ metadata, transports, own }: MemberContext,
): RTCMember {
const { userId, deviceId, memberId, rtcBackendIdentity, backendIdentities } =
membership$.value;
return {
local: userId === own.userId && deviceId === own.deviceId,
// Read off the membership for now; the backend will derive them once the
// identity scheme is its own and not the js-sdk's
rtcBackendIdentity,
backendIdentities,
userId,
deviceId,
memberId,
displayName$: scope.behavior(
metadata
.createDisplayNameBehavior$(scope, userId)
.pipe(map((name) => name ?? userId)),
),
avatarUrl$: metadata.createAvatarUrlBehavior$(scope, userId),
transport$: scope.behavior(
membership$.pipe(
map((membership) => membership.getTransport()),
distinctUntilChanged(),
map((transport) => transport && transports.get(transport)),
),
),
applicationData$: scope.behavior(
membership$.pipe(
map((membership) => membership.applicationData),
distinctUntilChanged(deepCompare),
),
),
};
}
/** The participation's view of a remote member: the slot's member plus its media. */
export function createRemoteRTCMember(
member: RTCMember,
media: MemberMedia,
): RemoteRTCMember {
return { ...member, local: false, ...media };
}
export function createLocalRTCMember(
member: RTCMember,
media: LocalMemberMedia,
): LocalRTCMember {
return { ...member, local: true, ...media };
}
@@ -15,25 +15,21 @@ import {
} from "vitest";
import { MatrixError } from "matrix-js-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import {
type LivekitTransportConfig,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
TransportAutoDiscovery,
type TransportAutoDiscoveryProps,
} from "./TransportAutoDiscovery.ts";
import type { ResolvedConfigOptions } from "../../../config/ConfigOptions.ts";
import {
RtcTransportAutoDiscovery,
type RtcTransportAutoDiscoveryProps,
} from "./RtcTransportAutoDiscovery.ts";
type DiscoveryClient = TransportAutoDiscoveryProps["client"];
type DiscoveryClient = RtcTransportAutoDiscoveryProps["client"];
const backendTransport: LivekitTransportConfig = {
const backendTransport: Transport = {
type: "livekit",
livekit_service_url: "https://backend.example.org",
};
const configTransport: LivekitTransportConfig = {
const configTransport: Transport = {
type: "livekit",
livekit_service_url: "https://config.example.org",
};
@@ -48,17 +44,7 @@ function makeClient(): MockedObject<DiscoveryClient> {
} as unknown as MockedObject<DiscoveryClient>;
}
function makeResolvedConfig(livekitServiceUrl?: string): ResolvedConfigOptions {
return {
livekit: livekitServiceUrl
? {
livekit_service_url: livekitServiceUrl,
}
: undefined,
} as ResolvedConfigOptions;
}
describe("RtcTransportAutoDiscovery", () => {
describe("TransportAutoDiscovery", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -73,9 +59,10 @@ describe("RtcTransportAutoDiscovery", () => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
const discovery = new RtcTransportAutoDiscovery({
const discovery = new TransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig(configTransport.livekit_service_url),
transportType: "livekit",
fallbackTransport: configTransport,
logger: rootLogger,
});
@@ -103,9 +90,10 @@ describe("RtcTransportAutoDiscovery", () => {
)
.mockResolvedValue([backendTransport]);
const discovery = new RtcTransportAutoDiscovery({
const discovery = new TransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig("https://config.example.org"),
transportType: "livekit",
fallbackTransport: configTransport,
logger: rootLogger,
});
@@ -126,9 +114,10 @@ describe("RtcTransportAutoDiscovery", () => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
const discovery = new RtcTransportAutoDiscovery({
const discovery = new TransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig(configTransport.livekit_service_url),
transportType: "livekit",
fallbackTransport: configTransport,
logger: rootLogger,
});
@@ -142,9 +131,10 @@ describe("RtcTransportAutoDiscovery", () => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue([]);
const discovery = new RtcTransportAutoDiscovery({
const discovery = new TransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig(undefined),
transportType: "livekit",
fallbackTransport: undefined,
logger: rootLogger,
});
@@ -0,0 +1,95 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { doNetworkOperationWithRetry } from "../utils/network";
type TransportDiscoveryClient = Pick<
MatrixClient,
"getDomain" | "_unstable_getRTCTransports"
>;
export interface TransportAutoDiscoveryProps {
client: TransportDiscoveryClient;
/** The transport type the media backend serves. */
transportType: string;
/** A transport to use when the homeserver advertises none of the type. */
fallbackTransport?: Transport;
logger: Logger;
}
export class TransportAutoDiscovery {
private readonly client: TransportDiscoveryClient;
private readonly transportType: string;
private readonly fallbackTransport: Transport | undefined;
private readonly logger: Logger;
public constructor({
client,
transportType,
fallbackTransport,
logger,
}: TransportAutoDiscoveryProps) {
this.client = client;
this.transportType = transportType;
this.fallbackTransport = fallbackTransport;
this.logger = logger.getChild("[TransportAutoDiscovery]");
}
public async discoverPreferredTransport(): Promise<Transport | null> {
// 1) backend transports
const backendTransport = await this.tryBackendTransports();
if (backendTransport) {
this.logger.info(
`Found backend transport: ${JSON.stringify(backendTransport)}`,
);
return backendTransport;
}
// 2) the host's fallback
if (this.fallbackTransport) {
this.logger.info(
`Found fallback transport: ${JSON.stringify(this.fallbackTransport)}`,
);
return this.fallbackTransport;
}
return null;
}
/**
* Fetches the first transport of the backend's type from the homeserver.
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
* @private
*/
private async tryBackendTransports(): Promise<Transport | null> {
const client = this.client;
// MSC4143: Attempt to fetch transports from backend.
this.logger.info("First try to use getRTCTransports end point ...");
try {
const transportList = await doNetworkOperationWithRetry(async () =>
client._unstable_getRTCTransports(),
);
const first = transportList.find(
(transport) => transport.type === this.transportType,
);
if (first) {
return first;
} else {
this.logger.info(
`No ${this.transportType} transport found in getRTCTransports end point`,
transportList,
);
}
} catch (ex) {
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
}
return null;
}
}
+71
View File
@@ -0,0 +1,71 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it } from "vitest";
import { type ResolvedTransport } from "../api";
import {
type BackendConnection,
MediaConnectionState,
} from "../media-backend/api";
import { testScope } from "../utils/test";
import { createTransportRegistry, transportId } from "./Transports";
const sfu: Transport = { type: "livekit", livekit_service_url: "https://sfu" };
const resolved: ResolvedTransport = {
type: "livekit",
url: "wss://sfu",
token: "jwt",
roomAlias: "room",
identity: "me",
};
describe("transportId", () => {
it("does not depend on the order a sender wrote the keys in", () => {
expect(transportId({ type: "livekit", livekit_service_url: "u" })).toBe(
transportId({ livekit_service_url: "u", type: "livekit" }),
);
});
it("tells transports of the same type apart", () => {
expect(transportId(sfu)).not.toBe(
transportId({ ...sfu, livekit_service_url: "https://other" }),
);
});
});
describe("createTransportRegistry", () => {
it("hands out one metadata object per transport", () => {
const registry = createTransportRegistry(
testScope(),
new BehaviorSubject<BackendConnection[]>([]),
);
expect(registry.get(sfu)).toBe(registry.get({ ...sfu }));
expect(registry.get(sfu).id).toBe(transportId(sfu));
});
it("derives the connected transports and what was resolved from the backend's connections", () => {
const connections$ = new BehaviorSubject<BackendConnection[]>([]);
const registry = createTransportRegistry(testScope(), connections$);
const metadata = registry.get(sfu);
expect(registry.connected$.value).toEqual([]);
expect(metadata.resolved$.value).toBeUndefined();
connections$.next([
{ transport: sfu, state: MediaConnectionState.Connecting, resolved },
]);
expect(registry.connected$.value).toEqual([]);
expect(metadata.resolved$.value).toBe(resolved);
connections$.next([
{ transport: sfu, state: MediaConnectionState.Connected, resolved },
]);
expect(registry.connected$.value).toEqual([metadata]);
});
});
+85
View File
@@ -0,0 +1,85 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { distinctUntilChanged, map } from "rxjs";
import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type TransportMetadata } from "../api";
import { type MediaBackend, MediaConnectionState } from "../media-backend/api";
export interface TransportRegistry {
/** The one `TransportMetadata` for a transport, however many memberships name it. */
get(raw: Transport): TransportMetadata;
/** Transports with a live connection. */
connected$: Behavior<TransportMetadata[]>;
}
export function createTransportRegistry(
scope: ObservableScope,
connections$: MediaBackend["connections$"],
): TransportRegistry {
const transports = new Map<string, TransportMetadata>();
const get = (raw: Transport): TransportMetadata => {
const id = transportId(raw);
let transport = transports.get(id);
if (transport === undefined) {
transport = createTransportMetadata(scope, connections$, raw, id);
transports.set(id, transport);
}
return transport;
};
// Deduplicated: a backend may hold more than one connection to a transport,
// one per homeserver it has to authenticate through
const connected$ = scope.behavior(
connections$.pipe(
map((connections) => [
...new Set(
connections
.filter(({ state }) => state === MediaConnectionState.Connected)
.map(({ transport }) => get(transport)),
),
]),
),
);
return { get, connected$ };
}
function createTransportMetadata(
scope: ObservableScope,
connections$: MediaBackend["connections$"],
raw: Transport,
id: string,
): TransportMetadata {
return {
type: raw.type,
id,
raw,
resolved$: scope.behavior(
connections$.pipe(
map(
(connections) =>
connections.find((c) => transportId(c.transport) === id)?.resolved,
),
distinctUntilChanged(),
),
),
};
}
/**
* Transports are plain JSON out of membership events, so the same transport
* serialises the same way wherever it appears. Keys are sorted so that the
* order a sender wrote them in does not matter.
*/
export function transportId(raw: Transport): string {
return JSON.stringify(raw, Object.keys(raw).sort());
}
+113
View File
@@ -0,0 +1,113 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type MatrixRTCSession,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { describe, expect, it, type Mock, vi } from "vitest";
import { defaultSessionTimings, MatrixRTCMode } from "../config";
import { ownMemberMock } from "../utils/test";
import { joinJsSdkSession } from "./joinJsSdkSession";
const transport: Transport = {
type: "livekit",
livekit_service_url: "https://lk.example.org",
};
/** A session that only records the join; nothing else of it is touched. */
function session(): MatrixRTCSession & {
join: Mock<MatrixRTCSession["join"]>;
} {
return { join: vi.fn() } as unknown as MatrixRTCSession & {
join: Mock<MatrixRTCSession["join"]>;
};
}
describe("joinJsSdkSession", () => {
it("joins with the identity, the transport and the delayed leave timings it was given", () => {
const s = session();
const timings = {
...defaultSessionTimings,
waitForKeyRotationMs: 500,
membershipEventExpiryMs: 60_000,
keyRotationParticipantLimit: 20,
};
joinJsSdkSession(s, ownMemberMock, transport, {
encryptMedia: true,
matrixRTCMode: MatrixRTCMode.Compatibility,
sendNotificationType: "ring",
timings,
delayedLeave: {
delay_ms: 3_600_000,
restart_ms: 300_000,
restart_timeout_ms: 5_000,
},
});
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transport], {
notificationType: "ring",
callIntent: undefined,
applicationData: {},
manageMediaKeys: true,
delayedLeaveEventRestartMs: 300_000,
delayedLeaveEventDelayMs: 3_600_000,
delayedLeaveEventRestartLocalTimeoutMs: 5_000,
networkErrorRetryMs: 1_000,
makeKeyDelay: 500,
membershipEventExpiryMs: 60_000,
keyRotationParticipantLimit: 20,
unstableSendStickyEvents: false,
// The sync grace period is the shorter wait: 10 s at 1 s per retry, plus one
maximumNetworkErrorRetryCount: 11,
});
});
it("gives up on the network once the delayed leave has probably been sent", () => {
const s = session();
joinJsSdkSession(s, ownMemberMock, transport, {
encryptMedia: false,
matrixRTCMode: MatrixRTCMode.Compatibility,
timings: { ...defaultSessionTimings, networkErrorRetryMs: 4_000 },
delayedLeave: { delay_ms: 18_000 },
});
expect(s.join).toHaveBeenCalledWith(
ownMemberMock,
[transport],
expect.objectContaining({
manageMediaKeys: false,
maximumNetworkErrorRetryCount: 4,
}),
);
});
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
const s = session();
joinJsSdkSession(s, ownMemberMock, transport, {
encryptMedia: true,
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
applicationData: { "m.call.intent": "audio", custom: 1 },
timings: defaultSessionTimings,
delayedLeave: defaultSessionTimings.delayedLeave,
});
expect(s.join).toHaveBeenCalledWith(
ownMemberMock,
[transport],
expect.objectContaining({
callIntent: "audio",
applicationData: { custom: 1 },
unstableSendStickyEvents: true,
}),
);
});
});
+77
View File
@@ -0,0 +1,77 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type MatrixRTCSession as JsSdkRTCSession,
type RTCCallIntent,
type RTCNotificationType,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import {
type DelayedLeaveTimings,
MatrixRTCMode,
type SessionTimings,
} from "../config";
interface Options {
encryptMedia: boolean;
matrixRTCMode: MatrixRTCMode;
sendNotificationType?: RTCNotificationType;
applicationData?: Record<string, unknown>;
timings: SessionTimings;
/** The delayed leave timings to use: delegated to the SFU or not. */
delayedLeave: DelayedLeaveTimings;
}
/**
* Sends the membership and starts the membership manager, which keeps it
* alive and retries on failure.
* @throws If the join could not be started.
*/
export function joinJsSdkSession(
session: JsSdkRTCSession,
ownMembershipIdentity: CallMembershipIdentityParts,
transport: Transport,
{
encryptMedia,
matrixRTCMode,
sendNotificationType,
applicationData,
timings,
delayedLeave,
}: Options,
): void {
// The membership format carries the intent as a field of its own
const { "m.call.intent": intent, ...rest } = applicationData ?? {};
const callIntent =
typeof intent === "string" ? (intent as RTCCallIntent) : undefined;
// Give up on the network as soon as either the sync has been down for the
// grace period or the delayed leave has probably been sent, whichever is
// sooner
const maxWaitMs = Math.min(
timings.syncDisconnectGracePeriodMs,
delayedLeave.delay_ms,
);
session.join(ownMembershipIdentity, [transport], {
notificationType: sendNotificationType,
callIntent,
applicationData: rest,
manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
delayedLeaveEventRestartLocalTimeoutMs: delayedLeave.restart_timeout_ms,
networkErrorRetryMs: timings.networkErrorRetryMs,
makeKeyDelay: timings.waitForKeyRotationMs,
membershipEventExpiryMs: timings.membershipEventExpiryMs,
keyRotationParticipantLimit: timings.keyRotationParticipantLimit,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
maximumNetworkErrorRetryCount:
Math.ceil(maxWaitMs / timings.networkErrorRetryMs) + 1,
});
}
+187
View File
@@ -0,0 +1,187 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* The public media types of the SDK: what a member publishes, the tracks it
* arrives as and how a host renders them. The client and member types are in
* `api.ts`.
*/
import { type Observable } from "rxjs";
import { type Behavior } from "./reactive/Behavior";
export interface AudioCaptureSettings {
echoCancellation?: boolean;
noiseSuppression?: boolean;
autoGainControl?: boolean;
}
export type VideoCodec = "vp8" | "h264" | "vp9" | "av1" | "h265";
export interface VideoCaptureSettings {
resolution?: { width: number; height: number; frameRate?: number };
maxBitrate?: number;
maxFramerate?: number;
codec?: VideoCodec;
}
/**
* Transforms a camera track before it is published: background blur and the
* like. `init` resolves with the track to publish in place of the captured
* one; `restart` does the same for a new capture, where the camera changes.
*/
export interface VideoProcessor {
name: string;
init(
track: MediaStreamTrack,
element?: HTMLMediaElement,
): Promise<MediaStreamTrack>;
restart(
track: MediaStreamTrack,
element?: HTMLMediaElement,
): Promise<MediaStreamTrack>;
destroy(): Promise<void>;
}
/**
* One thing to publish: a source, where to capture it from and how to encode
* it. Where the device and the settings are left out, the browser's and
* LiveKit's defaults apply.
*/
export type PublishRequest =
| { source: "microphone"; deviceId?: string; capture?: AudioCaptureSettings }
| {
source: "camera";
deviceId?: string;
/** Background blur and the like, applied before the first frame is published. */
processor?: VideoProcessor;
capture?: VideoCaptureSettings;
}
| {
source: "screenShare";
/** Whether to capture the screen's audio too. Default true. */
audio?: boolean;
capture?: VideoCaptureSettings;
};
export type MediaSource =
| "microphone"
| "camera"
| "screenShare"
| "screenShareAudio"
/** Published without a source; the application knows what it is. */
| "unknown";
export type MediaStreamStats =
| RTCInboundRtpStreamStats
| RTCOutboundRtpStreamStats
| undefined;
/** One published track of a member. */
export interface MediaTrack {
source: MediaSource;
kind: "audio" | "video";
/** Stable for the life of the track. */
id: string;
muted$: Behavior<boolean>;
/**
* False when the SFU reports the track as unencrypted. Fixed for the life
* of the track: a publisher that changes its encryption publishes anew.
*/
encrypted: boolean;
/**
* Inbound statistics for a remote track, outbound for a local one, polled
* every `intervalMs` while the observable is subscribed and not at all
* otherwise. Each subscription runs its own poll, so a view shares one.
*/
stats$(intervalMs: number): Observable<MediaStreamStats>;
/**
* Rendering. The view hands its `<video>` or `<audio>` element over; the SDK
* sets its stream and, for video, registers the size and on-screen observers
* that pick a simulcast layer and pause the subscription while the element is
* hidden. `attach` is idempotent per element; `detach` has to be called
* before the element leaves the DOM so those observers are released.
*/
attach(element: HTMLMediaElement): void;
detach(element: HTMLMediaElement): void;
}
export interface AudioMediaTrack extends MediaTrack {
kind: "audio";
/**
* Whether the track carries sound right now, as the backend measures it.
* False while muted. A call reads this on the microphone track and calls it
* "speaking".
* This is different to audioLevel = 0. The backend might run some hysteresis on it.
*/
isActive$: Behavior<boolean>;
/**
* How loud the track is right now, 0 to 1, as the backend measures it. 0
* while muted. Not what decides `isActive$`: the backend applies its own
* threshold and hysteresis to that, so a level above 0 may not be active and
* an active track may briefly sit at 0.
*/
audioLevel$: Behavior<number>;
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
setVolume(volume: number): void;
}
export interface VideoMediaTrack extends MediaTrack {
kind: "video";
}
/** The controls a member has over a track it publishes itself. */
export interface LocalMediaTrack {
/**
* Mutes or unmutes. Resolves with the state that resulted, which differs
* from the request where the device could not be used.
*/
setEnabled(enabled: boolean): Promise<boolean>;
/** Captures from another device. Rejects for a screen share, which has none. */
setDevice(deviceId: string): Promise<void>;
}
export interface LocalAudioMediaTrack
extends AudioMediaTrack, LocalMediaTrack {}
export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
/** For mirroring; undefined where the camera does not say which way it faces. */
facingMode$: Behavior<"user" | "environment" | undefined>;
/**
* Restarts the camera facing the other way, on devices with a front and a
* back camera, and resolves with the id of the device now in use. Does
* nothing where the facing mode is unknown.
*/
switchFacingMode(): Promise<string | undefined>;
/** Background blur and the like; undefined removes the processor. */
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
}
export type EncryptionError = "MissingKey" | "InvalidKey";
/**
* What a member sends, once it has arrived on its transport: its tracks, null
* while nothing has arrived for it, and the key errors beside them. The media
* backend supplies exactly these two fields per member.
*/
export interface MemberMedia {
/**
* The member's tracks, in publication order, once it has shown up on its
* transport; null until then ("waiting for media"). An entry stays the same
* object for as long as the same publication is behind it. Which track is
* which is in its `source`; `trackBySource$` picks one out.
*/
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
/** Emits when the SFU reports a key problem for this member. */
encryptionError$: Observable<EncryptionError>;
}
export interface LocalMemberMedia extends MemberMedia {
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
}

Some files were not shown because too many files have changed in this diff Show More