Note for agents: If you discover useful context about this codebase during a conversation — architectural patterns, naming conventions, API design decisions, gotchas — please add it to this file in the background so future agent interactions benefit from the knowledge.
This is the LiveKit client SDK for JavaScript/TypeScript (livekit-client). It provides the
browser-side implementation for connecting to LiveKit rooms and interacting with
audio, video, data streams, data tracks, and RPC.
A Room is the central object. It connects to a LiveKit server and exposes localParticipant
(the current user) and remoteParticipants (everyone else). Room events (RoomEvent.*) are
the primary way to react to state changes — tracks published, participants joining/leaving, etc.
Important: event handlers should be registered before calling room.connect() because some
events (like DataTrackPublished) fire during the connection handshake.
When changing full reconnects, carry the attempt's AbortSignal through joins and region
selection. Cancellation must cover continuations after awaited cleanup and late join responses:
engine close removes room listeners, so room-event guards cannot catch those continuations.
Regression tests live in RTCEngine.test.ts and SignalClient.test.ts.
Each region attempt of a full reconnect has its own AbortSignal, passed as the second argument of
EngineEvent.SignalRestarted. The engine aborts it when the next attempt begins (next region or a
later restart) and on close. CoreRoom.handleSignalRestarted awaits a republish, so it checks that
signal before it reports the recovery. Failover regression tests live in Room.test.ts.
LiveKit has four data transport mechanisms, each suited to different patterns:
- Data packets — one-shot messages, reliable or lossy delivery
- Text streams / byte streams — finite reliable streams (chat, file transfer, LLM responses)
- RPC — request-response over the room
- Data tracks — continuous unreliable streams for real-time data (sensor telemetry, robot teleoperation). This is the newest addition.
Data tracks have a publish side (LocalDataTrack) and a subscribe side (RemoteDataTrack).
The publish/subscribe lifecycle is managed by OutgoingDataTrackManager and
IncomingDataTrackManager respectively.
Key design decisions:
RemoteDataTrack.subscribe()is synchronous — it returns aReadableStreamimmediately. The SFU subscription is initiated lazily inside the stream'sstartcallback.- The
subscribe()signal parameter follows the fetch API pattern: a singleAbortSignalcontrols both the pending SFU negotiation phase and the active streaming phase. - Each subscription has an internal frame buffer. When the buffer fills, new frames are dropped
(not queued infinitely). Buffer size is configurable via
subscribe({ bufferSize }). openSubscriptionStream()on the manager returns a tuple:[ReadableStream, Promise<void>]where the promise resolves when the SFU subscription is fully established. This is primarily useful for tests.
Data stream readers (ByteStreamReader, TextStreamReader) implement async iteration with
abort signal support. They extend a BaseStreamReader base class. The withAbortSignal()
method on these readers is @internal — it exists for readAll() but is not meant as
user-facing API.
- Type check:
npx tsc --noEmit - Run all tests:
npx vitest run - Run specific test file:
npx vitest run path/to/file.test.ts - Format:
npm run format - Published types:
pnpm check:published-types(afterpnpm build) installs the packed tarball into a temp project outside the repo and type-checks every entry withskipLibCheckoff andexactOptionalPropertyTypeson. Declarations must name only packages independencies, and their@types/*packages must be there too. ImportThrowsfromsrc/utils/throws.ts, never from@livekit/throws-transformer(a devDependency). Ifdiststill shows an old inferred type after a type-only change, deletenode_modules/.cache/rollup-plugin-typescript2: its cache misses those.
Standalone example apps live in examples/. Each has its own package.json, vite.config.js,
and an Express API backend (api.ts) for token generation via vite-plugin-mix. To run one:
cd examples/<name>
pnpm install
pnpm dev
The main demo app (examples/demo/) is a comprehensive kitchen-sink UI. Standalone examples
(examples/rpc/, examples/data-tracks/) focus on individual features.
Room (the main export) is CoreRoom plus every extension, installed in its constructor through
installExtensions(). The core entry's createRoom(options, [extensions]) does the same with the
list an app gives it.
CoreRoom (src/room/CoreRoom.ts) holds the signal client, engine, participants, media and
raw data packets. Everything else is a RoomExtension (src/room/extensions.ts) that
installExtensions() installs right after construction. src/exports.ts is the export surface both
entries share; src/index.ts adds Room and the type augmentations, src/core.ts adds
CoreRoom and the extension objects (livekit-client/core, experimental). The core entry is
built per module with dependencies external (dist/core/), so a consumer's bundler drops unused
modules whole; the main entry stays one self-contained bundle. pnpm check:core fails if
CoreRoom alone bundles an extension module, and pnpm size-limit tracks both entries.
- An extension is a plain object:
key, optionalrequires(hard dependencies, installed first, deduped by key), andinstall(room, ctx). It lives inextension.tsnext to its managers. installreturns an intersection of small interfaces, and each extension declares exactly the ones it uses as its return type, so a missing API or an undeclared contribution is a type error:RoomApiProvider<RoomApi>(room, copied onto the room),LocalParticipantApiProvider<LocalParticipantApi>(localParticipant, copied onto the local participant),Disposable,EngineHooks(onEngineCreated),ConnectionHooks(onDisconnect),ParticipantHooks,DataPacketOwner(dataPackets, one owner per case),SyncStateProvider, andSlotProvider<K>for the values core reads (Pick<CoreSlots, K>;dataStreamsis aSlotProvider<'byteStreamOpener'>). The slots are one keyed store: a new one is a documented member ofCoreSlots, its key inSLOT_KEYS(core copies those off every result), and the read in core. A provider whose value is conditional declaresPartial<SlotProvider<...>>(e2eewithout options). Rule for a new interface: one per capability, named for what core does with it,*Providerfor a value core reads,*Hooksfor a moment core calls,*Ownerfor an exclusive claim.ExtensionResultis the union of all of them, optional, for the resolver. Extra members (the managers) are visible to dependents throughctx.get(extension). The room's typing (ExtensionApis) comes from theRoomExtension<RoomApi, LocalParticipantApi>generics pinned withsatisfies, not from the result. Nothing registers onExtensionContext; it offerslog,get,slotand the two privileged operations.- Extensions that take options (
e2ee,frameMetadata,dataStreams) are built withdefineExtension()and are callable: listed bare they read today'sRoomOptions, called with options they use those, and a function is evaluated per room. Extensions without options are plain objects. The resolver dedupes by key, installs dependencies first, lets an explicitly listed instance win over a dependency's default, and throws when a key is listed twice. install,disposeand every hook are synchronous. Async setup starts ininstalland is awaited in the extension's own methods.- Core never reads an extension's slice of the room options (
e2ee,encryption,frameMetadata,packetTrailer,dataStream): a configured extension (e2ee({ ... })) does not write them. What core needs comes through a provider member of the install result (e2eeManager: manager), which the extension fills from its own options or, when listed bare, fromRoomOptions. Every slot of the list is filled before the firstonEngineCreatedruns, so an extension that depends on another's slot readsctx.slot()there.pnpm check:coregreps the modules for reads of another extension's slice. - A worker an app supplies to an extension is terminated in the extension's
dispose, not on disconnect: the room can connect again after a disconnect. The exception is a manager the app passes in (e2eeManager): it can be shared between rooms, so the extension neither disposes nor terminates it. - Core calls out through the install result's hooks only. The engine is replaced after a close,
so register engine listeners in
onEngineCreated. Prefer an existingRoomEventover a new hook; a new hook is an optional member on one of the small interfaces plus a loop overextensionResultsinCoreRoom. - The full entry (
src/room/Room.ts) augmentsCoreRoomandLocalParticipantwith the extension API interfaces viadeclare module, so the full build keeps its types wherever those classes appear. Inside the SDK's own compilation this means core code can call extension methods without a type error;pnpm check:coreis the guard. - Extension method bodies must be closures over the managers, never
this-based: they are copied onto instances withObject.defineProperties. - The core declarations (
dist/core/types) come from a program rooted atsrc/core.ts(rollupfiles: ['src/core.ts']), sosrc/index.tsand itsdeclare moduleaugmentations are not in it. That is the guard against the augmentations reaching a core consumer: tsc's declaration emitter writesimport("../..")for an inferred type whenever the index barrel is in its program, and that import would type a[rpc]room as if every extension were installed. Still, never import types from the barrel ('../..','.') insidesrc. Inferred types on exported values are fine.pnpm check:corecompiles a consumer probe against declarations emitted the same way and fails on a leak or a dangling reference. - The core build emits its own declarations with
stripInternal(dist/core/types); the main entry keeps@internalmembers because components-js and React Native use some of them. Every symbol a public signature names must therefore stay untagged (ExtensionContext,InternalRoomOptions, theRTCEngineclass), or the stripped declaration dangles. Tag members, not the classes and interfaces that public types mention.pnpm check:coretype-checks the stripped declarations withskipLibCheckoff and fails on a dangling reference. - Operations that exist only for extensions live on
ExtensionContext, not onCoreRoom:getOrCreateParticipant()andsimulateConnected(). The room keeps the slots in onePartial<CoreSlots>object that it hands toLocalParticipant(thebyteStreamOpenerfor the preconnect audio buffer, thevideoPublisher).installExtensions()is the only@internalmemberCoreRoomhas for extensions. Room.dispose()disconnects, runs extensiondisposein reverse order and removes thedevicechangelistener. A disposed room cannot connect again.simulateParticipantsis thesimulatedParticipantsextension (src/room/simulated-participants/); it usesctx.simulateConnected()andctx.getOrCreateParticipant().simulateScenariostays in core. The legacy chat packets (sendChatMessage,RoomEvent.ChatMessage) are thechatextension (src/room/chat/): the fullRoominstalls it, the core entry does not export it because text streams supersede it.- Video publishing is the
videoextension (src/room/video/). Receiving video stays in core. Core keeps type guards (isLocalVideoTrack) and instance method calls on video tracks, which cost nothing; it must not importLocalVideoTrack,publishUtilsorfacingModeas values. Two slots connect them: thevideoPublisherslot on the local participant (the publish pipeline for a video track: codec, encodings, layers, start bitrate, server codec fallback, screen capture, thevideoPublisherslot of the install result) andsetVideoCapture()intrack/create.ts(howcreateLocalTracksbuilds a video track). The full entry callsregisterVideoCapture()at module load socreateLocalTracks({ video })works before any Room exists.
Managers (e.g. RpcClientManager, RpcServerManager, OutgoingDataTrackManager,
IncomingDataTrackManager) follow a consistent decoupled architecture:
- No RTCEngine dependency. Managers never import or hold a reference to
RTCEngine.- Not all managers currently abide by this, but it is a good goal for newly introduced managers to follow unless it is overly burdensome.
- Incoming data arrives via explicit
handle*methods (e.g.handleIncomingRpcAck,handleIncomingDataStream).Roomparses incoming packets and calls these directly. - Outgoing data is emitted via strongly-typed events using
typed-emitter. Managers extend(EventEmitter as new () => TypedEmitter<ManagerCallbacks>)andRoomsubscribes to these events to forward packets to the engine. - External state (e.g. remote participant protocol version, server version) is injected as callbacks in the constructor, not looked up from the engine.
- Event types are usually defined in an
events.tsfile alongside each manager. - Directory structure groups each manager with its events and tests:
src/room/rpc/client/containsRpcClientManager.ts,events.ts, andRpcClientManager.test.ts.
- Tests construct managers directly — no mock engine needed.
- Use
subscribeToEvents<ManagerCallbacks>(manager, ['eventName'])fromsrc/utils/subscribeToEvents.tsto capture emitted events. CreatemanagerEventsper-test (not inbeforeEach) so the subscription list can evolve independently. - Call
managerEvents.waitFor('eventName')to get emitted event payloads in order. - Use
managerEvents.areThereBufferedEvents('eventName')to assert no unexpected events. - Prefer plain
async () => { ... }handlers overvi.fn().mockResolvedValue()unless the test needs to assert on call arguments.
- The codebase uses a
@throws-transformerthat convertsthrowstatements into typed error return types. Panics (programmer errors) are annotated with// @throws-transformer ignore. Future<T, E>is a local utility similar to a deferred promise — it exposesresolve/rejectcallbacks and a.promiseproperty.- Error classes use a
Reasonedpattern:DataTrackSubscribeError<Reason>with areasonenum and static factory methods (.timeout(),.cancelled(),.disconnected()).