@observertc/observer-js 1.0.0-beta.11 → 1.0.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms-full.txt CHANGED
@@ -257,8 +257,7 @@ observer.addAcceptMiddleware(route, filter);
257
257
  // observer.removeAcceptMiddleware(route);
258
258
  ```
259
259
 
260
- This is a lightweight global injection point, distinct from the larger (not-yet-built)
261
- `ClientSampleProcessor` pipeline in the roadmap. When no middleware is registered, `accept()`
260
+ This is a lightweight global injection point. When no middleware is registered, `accept()`
262
261
  dispatches directly with no overhead.
263
262
 
264
263
  ### `context` (the `AcceptContext`)
@@ -421,7 +420,6 @@ See [Mediasoup router observation](#mediasoup-router-observation) for the full d
421
420
  | `peer-connection-added` / `peer-connection-closed` | — | lifecycle of the PC |
422
421
  | `peer-connection-updated` | `{ context?: AcceptContext }` | the PC processed a sample |
423
422
  | `ice-connection-state-changed` / `ice-gathering-state-changed` / `connection-state-changed` | `{ state: string }` | driven by client events |
424
- | `selected-candidate-pair-changed` | — | *declared; not currently emitted* |
425
423
  | `inbound-track-added` / `-updated` / `-removed` / `-muted` / `-unmuted` | `{ observedInboundTrack }` | |
426
424
  | `outbound-track-added` / `-updated` / `-removed` / `-muted` / `-unmuted` | `{ observedOutboundTrack }` | |
427
425
  | `inbound-rtp-added` / `-updated` / `-removed` | `{ observedInboundRtp }` | `-updated` fires every tick |
@@ -474,8 +472,8 @@ type ObserverConfig<AppData = Record<string, unknown>> = {
474
472
  createClientAppData?: (p: { clientId: string; observedCall: ObservedCall }) => Record<string, unknown>;
475
473
  // sink factory — produces a per-client sink that receives every accepted sample (see Sinks).
476
474
  createClientSink?: (p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined;
477
- // track-resolver factory — produces a call's RemoteTrackResolver (see Remote track resolution).
478
- createTrackResolver?: (observedCall: ObservedCall) => RemoteTrackResolver | undefined;
475
+ // remote-track-resolver factory — produces a call's RemoteTrackResolver (see Remote track resolution).
476
+ createRemoteTrackResolver?: (observedCall: ObservedCall) => RemoteTrackResolver | undefined;
479
477
  };
480
478
  ```
481
479
 
@@ -529,7 +527,7 @@ Key members:
529
527
  - `addIssue(issue: ClientIssue): void` — raise a **call-level** issue → emits `call-issue`
530
528
  - `readonly detectors: Detectors` — server-side detector registry (empty by default; see [Detectors](#detectors-server-side-extension-point))
531
529
  - `scoreCalculator: ScoreCalculator`, `get score()`, `readonly calculatedScore`
532
- - `remoteTrackResolver?: RemoteTrackResolver` — set from `ObserverConfig.createTrackResolver` at call creation (see [Remote track resolution](#remote-track-resolution-mediasoup--sfu))
530
+ - `remoteTrackResolver?: RemoteTrackResolver` — set from `ObserverConfig.createRemoteTrackResolver` at call creation (see [Remote track resolution](#remote-track-resolution-mediasoup--sfu))
533
531
  - aggregates: `numberOfIssues`, `numberOfPeerConnections`, `numberOfInboundRtpStreams`,
534
532
  `numberOfOutboundRtpStreams`, `numberOfDataChannels`, `maxNumberOfClients`,
535
533
  `clientsUsedTurn: Set<string>`, `startedAt?`, `endedAt?`, `closedAt?`, `closed`
@@ -764,7 +762,7 @@ class Detectors {
764
762
 
765
763
  In an SFU, one participant's **outbound** track is delivered to other participants as **inbound**
766
764
  tracks (one **publisher** → many **subscribers**). Correlation is **opt-in** per observer: set
767
- `ObserverConfig.createTrackResolver`, a factory invoked when each call is created that returns the
765
+ `ObserverConfig.createRemoteTrackResolver`, a factory invoked when each call is created that returns the
768
766
  call's `RemoteTrackResolver` (or `undefined` for none).
769
767
 
770
768
  `RemoteTrackResolver` is a generic, strategy-driven class. It subscribes to the bus (filtered to
@@ -775,7 +773,7 @@ the tracks: `inboundTrack.remoteOutboundTrack` and `outboundTrack.remoteInboundT
775
773
  import { Observer, createDefaultMediasoupRemoteTrackResolverFactory } from '@observertc/observer-js';
776
774
 
777
775
  const observer = new Observer({
778
- createTrackResolver: createDefaultMediasoupRemoteTrackResolverFactory(),
776
+ createRemoteTrackResolver: createDefaultMediasoupRemoteTrackResolverFactory(),
779
777
  });
780
778
 
781
779
  // later, given tracks (links are kept up to date as tracks come and go):
@@ -794,7 +792,7 @@ id is just whatever links a subscribed track to the published one:
794
792
  import { Observer, RemoteTrackResolver } from '@observertc/observer-js';
795
793
 
796
794
  const observer = new Observer({
797
- createTrackResolver: (observedCall) => new RemoteTrackResolver(observedCall, {
795
+ createRemoteTrackResolver: (observedCall) => new RemoteTrackResolver(observedCall, {
798
796
  resolveOutboundTrackPublisherId: (out) => out.attachments?.mediaId as string | undefined,
799
797
  resolveInboundTrackPublisherId: (inb) => inb.attachments?.mediaId as string | undefined,
800
798
  resolveInboundTrackSubscriberId: (inb) => inb.attachments?.subId as string | undefined, // optional
@@ -818,19 +816,37 @@ transitions. `ObservedMediasoupRouter` captures that server-side view into a
818
816
  ### The concept
819
817
 
820
818
  You hand the observer a live mediasoup `Router`; it attaches to mediasoup's own `observer` API and,
821
- from then on, **passively records** the router's topology and lifecycle — with no polling and no
819
+ from then on, **passively tracks** the router's topology and lifecycle — with no polling and no
822
820
  changes to your media code:
823
821
 
824
- - new transports (`webrtc` / `plain` / `pipe` / `direct`), their selected `tuple`, and ICE state
825
- transitions;
822
+ - new transports (`webrtc` / `plain` / `pipe` / `direct`), their selected `tuple`, ICE/DTLS/SCTP
823
+ state transitions and `connectedAt`;
826
824
  - producers (codec, SSRCs/RIDs, `pause`/`resume`) and consumers (`pause`/`resume`,
827
825
  `producerPaused`/`producerResumed`);
828
826
  - data producers and data consumers;
829
827
  - `createdAt` / `closedAt` for every entity above.
830
828
 
831
- All of it accumulates on `observedMediasoupRouter.sample` (a `MediasoupRouterSample` — see
832
- [`src/schema/MediasoupRouter.ts`](./src/schema/MediasoupRouter.ts)). This is a *Sample*, not a
833
- *Report*: it mirrors the naming of `ClientSample` and is yours to snapshot, persist, or correlate.
829
+ It keeps all of this **in memory**, in a single `MediasoupRouterSample` exposed as
830
+ `observedRouter.sample` — see [`src/schema/MediasoupRouter.ts`](./src/schema/MediasoupRouter.ts). The
831
+ sample **accumulates for the life of the router**: closed transports/producers/consumers are kept
832
+ (with their `closedAt` set), not removed. Read it whenever you like — it's a plain object you own.
833
+
834
+ ### Memory & large meetings
835
+
836
+ This is intentionally the **simplest** approach — everything lives in memory and nothing is sampled
837
+ or evicted for you. That's fine for typical rooms, but be aware of the cost at scale:
838
+
839
+ - **Consumers grow as O(N²)** on a single flat router: with `N` participants each producing audio +
840
+ video and consuming everyone else, the sample holds roughly `2·N·(N−1)` consumer records (≈ 19,800
841
+ for `N` = 100).
842
+ - The sample is **cumulative** — closed entities and their `history` are retained — so it also grows
843
+ with call duration and churn (renegotiation, simulcast layer changes, rejoins).
844
+
845
+ A 100-participant flat router can therefore reach tens of MB and keep growing. There is **no built-in
846
+ sink, snapshotting, or eviction** — by design. **If you run large meetings, do your own sampling:**
847
+ on your own cadence read `observedRouter.sample` (snapshot/serialize/persist what you need), drop what
848
+ you don't, and close routers you no longer track. (mediasoup also typically shards routers across
849
+ workers/cores, which keeps any one router small.)
834
850
 
835
851
  ### Matching peer connections — by **event**, not by storage
836
852
 
@@ -853,32 +869,54 @@ router serving many participants emits one match per participant's transport. Wh
853
869
  or `false`, no matching is performed and the event never fires. The internal listener is removed
854
870
  automatically when the router closes or the observer closes.
855
871
 
872
+ ### Ordering contract — observe the router first
873
+
874
+ Matching is **forward-only by design**, and that is sufficient because the lifecycle ordering is
875
+ **guaranteed, not racy**:
876
+
877
+ - `ObservedMediasoupRouter` works purely by **subscribing to mediasoup's `observer` API**, so it can
878
+ only see events that happen *after* it is created. You therefore create it the moment the router
879
+ exists — **before** any transport is added to it — and it captures the rest going forward.
880
+ - A mediasoup transport is always created **on the server first**; only then can the client connect
881
+ to it, produce/consume, and begin shipping `ClientSample`s. So a peer connection — and the
882
+ `peer-connection-added` event it triggers — can never appear before its server-side WebRTC
883
+ transport already exists (and has been observed by the router).
884
+
885
+ Put together: by the time a `peer-connection-added` fires, the router has already recorded that
886
+ transport's id in `webrtcTransportIds`, so a single forward-looking listener catches every match. No
887
+ back-scan of existing peer connections and no re-check on transport creation are needed — the
888
+ observer deliberately does **not** look backwards.
889
+
890
+ **Your responsibility:** call `createObservedMediasoupRouter(...)` as early as the router exists
891
+ (before transports are added or samples are accepted). If you register the router *after* its
892
+ transports are created or after the client's first sample, those events are already in the past and
893
+ the corresponding matches are missed.
894
+
856
895
  When the underlying mediasoup router closes, its `close` propagates to `ObservedMediasoupRouter`,
857
- which emits **`mediasoup-router-removed`**. That is your cue to do whatever cleanup or persistence
858
- you want with the now-final `sample` — again, the observer itself keeps nothing.
896
+ which sets the sample's `closedAt` and emits **`mediasoup-router-removed`** — your cue to read /
897
+ persist the final `observedRouter.sample` and drop your reference to it.
859
898
 
860
899
  ### Options — `observer.createObservedMediasoupRouter(settings)`
861
900
 
862
901
  | Field | Type | Required | Meaning |
863
902
  |-------|------|----------|---------|
864
- | `router` | `mediasoup.types.Router` | yes | the live router to observe; the observer attaches to `router.observer` |
865
- | `routerId` | `string` | yes | your id for the router (the sample also carries `router.id`) |
903
+ | `router` | `mediasoup.types.Router` | yes | the live router to observe; the observer attaches to `router.observer`. `.id` and the sample's `routerId` come from `router.id` |
866
904
  | `appData` | `Record<string, unknown>` | no | application-owned bag on the `ObservedMediasoupRouter` |
867
- | `attachments` | `Record<string, unknown>` | no | free-form data copied onto `sample.attachments` |
905
+ | `attachments` | `Record<string, unknown>` | no | free-form data; carried on `sample.attachments` |
868
906
  | `matchPeerConnectionByWebRtcTransportId` | `boolean` | no | opt in to peer-connection matching: emit `mediasoup-router-matched-with-peer-connection` for each peer connection whose id matches one of the router's WebRTC transport ids. Omitted / `false` → no matching, the event never fires |
869
907
 
870
908
  Peer-connection matching is **off by default**; enable it with
871
909
  `matchPeerConnectionByWebRtcTransportId: true`. Returns the `ObservedMediasoupRouter`, or `undefined`
872
910
  if the observer is closed (a router with the same id returns the existing instance — both warn).
873
911
 
874
- Useful members on the returned object: `.sample` (the `MediasoupRouterSample`), `.appData`,
875
- `.attachments` (getter over `sample.attachments`), `.webrtcTransportIds: Set<string>`, `.id`,
876
- `.close()`.
912
+ Useful members on the returned object: `.sample` (the in-memory `MediasoupRouterSample`, with
913
+ `createdAt` / `closedAt?` on it), `.appData`, `.attachments`, `.webrtcTransportIds: Set<string>`,
914
+ `.id`, `.close()`.
877
915
 
878
916
  ### Example
879
917
 
880
918
  ```ts
881
- import { Observer } from '@observertc/observer-js';
919
+ import { Observer, InMemorySink } from '@observertc/observer-js';
882
920
  import type { ObservedMediasoupRouterScope, ObservedPeerConnectionScope } from '@observertc/observer-js';
883
921
 
884
922
  const observer = new Observer();
@@ -886,46 +924,42 @@ const observer = new Observer();
886
924
  // 1) Feed client samples as usual so the observer knows about calls, clients & peer connections.
887
925
  // (e.g. transport-layer: observer.accept(clientSample, context))
888
926
 
889
- // 2) Observe the SFU side, opting in to peer-connection matching for this router's transports.
927
+ // 2) Observe the SFU side; opt in to peer-connection matching. State accumulates in `.sample`.
890
928
  const router = /* your mediasoup router */ undefined as any;
891
929
  const observedRouter = observer.createObservedMediasoupRouter({
892
930
  router,
893
- routerId: router.id,
894
931
  matchPeerConnectionByWebRtcTransportId: true,
895
932
  });
896
933
 
934
+ // For large meetings, sample it yourself on your own cadence (see "Memory & large meetings"):
935
+ // setInterval(() => persist(observedRouter.sample), 10_000);
936
+
897
937
  // 3) Every peer connection whose id matches one of the router's WebRTC transport ids fires this —
898
938
  // WE decide what to do with each pairing. The payload carries the full ancestry.
899
939
  observer.on('mediasoup-router-matched-with-peer-connection',
900
- ({ observedMediasoupRouter, observedCall, observedClient, observedPeerConnection }:
940
+ ({ observedMediasoupRouter, observedCall, observedPeerConnection }:
901
941
  ObservedMediasoupRouterScope & ObservedPeerConnectionScope) => {
902
- // e.g. remember which router serves this peer connection / client…
903
942
  (observedPeerConnection.appData ??= {}).routerId = observedMediasoupRouter.id;
904
- // …or index the server sample by call in your own store:
905
- myStore.linkRouterToCall(observedCall.callId, observedMediasoupRouter.sample);
943
+ myStore.linkRouterToCall(observedCall.callId, observedMediasoupRouter.id);
906
944
  },
907
945
  );
908
946
 
909
- // 4) The router closed — WE decide what to persist/forward with the final sample.
947
+ // 4) The router closed — read/persist the final state, then drop your reference.
910
948
  observer.on('mediasoup-router-removed', ({ observedMediasoupRouter }: ObservedMediasoupRouterScope) => {
911
- myStore.saveRouterSample(observedMediasoupRouter.sample);
912
- });
913
-
914
- // (optional) react to the router being registered at all:
915
- observer.on('mediasoup-router-added', ({ observedMediasoupRouter }) => {
916
- console.log('observing router', observedMediasoupRouter.id);
949
+ persist(observedMediasoupRouter.sample); // its `closedAt` is set
917
950
  });
918
951
  ```
919
952
 
920
- ### Why event-driven instead of storing on the call
953
+ ### Why event-driven matching instead of storing on the call
921
954
 
922
955
  - **Loose coupling.** The call model stays about client telemetry; the SFU view lives on its own
923
- object and is associated only if and how *you* choose.
956
+ `ObservedMediasoupRouter` and is associated only if and how *you* choose.
924
957
  - **You own the association.** One router serves many peer connections (across clients and calls),
925
958
  and the right place to keep that mapping is application-specific — so the observer hands you each
926
- peer-connection match and the final sample, and gets out of the way.
927
- - **No silent accumulation.** Nothing is appended to `ObservedCall`, so there is no hidden growth or
928
- lifetime you have to reason about; the router sample lives exactly as long as you keep a reference.
959
+ peer-connection match and gets out of the way.
960
+ - **You own the sampling.** The router sample is plain in-memory state you read on your own terms;
961
+ for large meetings, sample/persist it yourself (see [Memory & large meetings](#memory--large-meetings))
962
+ rather than relying on the library to evict — it deliberately doesn't.
929
963
 
930
964
  ---
931
965
 
@@ -1553,7 +1587,7 @@ the 1.0.0 API provides.
1553
1587
  track to the subscribed (inbound) tracks carrying it (**one publisher → many subscribers**) by a
1554
1588
  **publisher id** (the link key). Links are maintained directly on the tracks
1555
1589
  (`inboundTrack.remoteOutboundTrack`, `outboundTrack.remoteInboundTracks`).
1556
- - **Opt-in via `ObserverConfig.createTrackResolver`**, invoked per call. Built-in factories:
1590
+ - **Opt-in via `ObserverConfig.createRemoteTrackResolver`**, invoked per call. Built-in factories:
1557
1591
  `createDefaultMediasoupRemoteTrackResolverFactory()` (producerId/consumerId attachments) and
1558
1592
  `createP2pRemoteTrackResolverFactory()` (RTP SSRC). Custom topologies supply their own
1559
1593
  publisher/subscriber id resolvers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@observertc/observer-js",
3
- "version": "1.0.0-beta.11",
3
+ "version": "1.0.0-beta.12",
4
4
  "description": "Server-side Node.js library for processing ObserveRTC Samples",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",