@wtfalch/calls
v0.2.0
Published
Framework-independent SDK for Agora calls: session control (admission, guest links, moderation) and a media-ready browser call handle.
Readme
@wtfalch/calls
Framework-independent Calls SDK, in two entries.
@wtfalch/calls is the control client: session admission, guest links, moderation and guest credential status/rotation.
import { createCallsClient } from '@wtfalch/calls';
const calls = createCallsClient({ baseUrl: 'https://calls.example' });
await calls.joinAdmission(sessionId, { video: false, requestId: crypto.randomUUID() });waiting(sessionId) lists the guests waiting in the lobby, oldest first, for a host who holds calls.participants:admit. record(sessionId) reads what happened in a call: when it started and ended, and when each participant was admitted and left. Anyone who was in the call can read it, and so can holders of calls.records:read. A guest's copy carries no member's actorId.
Member requests use same-origin cookies and exact-origin requests. Guest link redemption returns a handle whose opaque credential remains private to the handle. Errors are typed CallsError; requests are bounded and are never retried automatically.
@wtfalch/calls/browser is the media-ready call handle. join() returns a CallHandle with:
state()andsubscribe()— a snapshot now and again on every change. It carriesparticipants, the roster as{ participantId, self, displayName, actorId?, media? }, so a changing roster needs no polling.mediais{ microphone, camera, screen, speaking, quality }, present when the adapter reports it, and a subscriber hears it change.publish('audio' | 'video' | 'screen')andstopShare().setMicrophoneEnabled()andsetMode()— mute and camera without leaving the call.attach(participantId, 'camera' | 'screen', videoElement)draws that participant's video and returns its detach. Your own id gives a local preview.devices()anduseDevice(kind, deviceId)— list and switch microphone, camera and speaker.ready()andleave().
When the call ends, the last snapshot a subscriber hears has ended: { reason }: left (you called leave()), admission_expired (a guest was never let in), control_refused (joining failed), session_ended (the host ended it, or its window closed), participant_removed (a moderator removed you) or admission_lapsed (your own admission or credential expired, or lost eligibility). connection_unavailable remains for the cases the service still cannot tell apart: a race with a sweep that has not run yet, or a still-admitted row whose binding/media credential alone lapsed.
The handle renders nothing itself. You inject the media adapter; @wtfalch/calls/livekit below is the shipped one. With an adapter that lacks attach, devices or useDevice, those methods throw an error with code media_unsupported.
displayName is untrusted text. A guest declares its own and the service bounds it by length only, so whatever draws it owns the escaping.
The handle also takes an endpoint store, so this entry touches no device and picks no storage itself. It needs the peer dependency @matrix-org/[email protected], which is required rather than optional: browser.mjs imports the crypto machine statically, so importing this entry without the peer throws ERR_MODULE_NOT_FOUND before any of your code runs. It is a peer and not a dependency because the engine needs exactly one instance of it.
@wtfalch/calls/livekit
A real media adapter for @wtfalch/calls/browser, built on livekit-client. It owns devices, tracks and the LiveKit room; createCallEndpoint owns authority, keys and lifecycle. It needs the peer dependency [email protected], and it is optional: only import this entry if you use it.
livekit-client's E2EE worker is your file to serve, not this package's. Create it once and hand it in:
import { createLiveKitAdapter } from '@wtfalch/calls/livekit';
import { createCallEndpoint } from '@wtfalch/calls/browser';
const worker = new Worker(
new URL('livekit-client/dist/livekit-client.e2ee.worker.mjs', import.meta.url),
{ type: 'module' },
);
const endpoint = createCallEndpoint({ adapter: createLiveKitAdapter({ worker }), store });
const call = await endpoint.join(/* ... */);
const detach = call.attach(participantId, 'camera', videoElement);The adapter never terminates that worker, so the same instance carries through every reconnect the engine drives. Terminate it yourself when you are done with the endpoint for good.
attach() throws an error with code track_unavailable when that source is not published and subscribed yet. Call it again once the participant's media shows the source on. Mute keeps the capture, so unmuting never asks for the microphone again. Remote audio plays on its own; there is nothing to attach for it.
For a test, pass capture: { audio, video, screen }, each a function returning a MediaStreamTrack, to publish synthetic media instead of opening real devices.
