@observertc/observer-js 1.0.0-beta.4 → 1.0.0-beta.6

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/README.md CHANGED
@@ -15,8 +15,9 @@ and emits a single, unified stream of typed events the application can react to.
15
15
  > agent) should be able to integrate the library, or develop it further, from this file alone.
16
16
  > A companion doc, [`docs/logging.md`](./docs/logging.md), covers logging integration in depth.
17
17
 
18
- > **Packaging:** the package is **ESM-only** and **server-side** (Node.js ≥ 16). Everything —
19
- > including the built-in file sink — is exported from the single `@observertc/observer-js` entry.
18
+ > **Packaging:** server-side, **Node.js ≥ 22**, shipped as a **dual ESM + CommonJS** build — so it
19
+ > works whether your project uses `import` (ESM) or `require()` (CommonJS). Everything — including
20
+ > the built-in file sink — is exported from the single `@observertc/observer-js` entry.
20
21
 
21
22
  ---
22
23
 
@@ -51,22 +52,20 @@ npm install @observertc/observer-js
51
52
  yarn add @observertc/observer-js
52
53
  ```
53
54
 
54
- **ESM-only, server-side.** The package ships ES Modules (`import`, not `require()`) and targets
55
- **Node.js ≥ 16**. Use it from ESM code (`"type": "module"`, or `.mjs`), or from TypeScript
56
- compiled to ESM. Everything is exported from the single `@observertc/observer-js` entry:
55
+ **Server-side, Node.js ≥ 22, dual ESM + CommonJS.** The package ships both module formats, so it
56
+ works the same whether your project is ESM or CommonJS — your import line is unchanged either way:
57
57
 
58
58
  ```ts
59
59
  import { Observer, ClientSample, createJsonlFileSinkFactory } from '@observertc/observer-js';
60
60
  ```
61
61
 
62
- Written in TypeScript; ships type declarations alongside the build (`dist/index.d.mts`). Runtime
62
+ In an ESM project this resolves to the `.mjs` build; in a CommonJS project (where TypeScript
63
+ compiles your `import` down to `require()`) it resolves to the `.js` build. Everything is exported
64
+ from the single `@observertc/observer-js` entry. Written in TypeScript; ships type declarations for
65
+ both formats (`dist/index.d.ts` for `require`, `dist/index.d.mts` for `import`). Runtime
63
66
  dependencies: `@bufbuild/protobuf`, `events`, `uuid`. The library does **not** bundle a logger or
64
67
  any transport — see [Logging](#logging).
65
68
 
66
- > **Note on `require()`.** Being ESM-only, the package can't be loaded with CommonJS
67
- > `require('@observertc/observer-js')`; consume it with `import` (or `await import()` from a CJS
68
- > module). If you need a CommonJS build, a dual ESM+CJS output is a small change — ask.
69
-
70
69
  `ClientSample` and friends are re-exported from this package, and are also published as the
71
70
  shared schema in [`@observertc/schemas`](https://github.com/observertc/schemas); samples
72
71
  produced on the client (e.g. by `@observertc/client-monitor-js`) conform to the same shape.
@@ -105,9 +104,8 @@ import { Observer, ClientSample } from '@observertc/observer-js';
105
104
 
106
105
  // 1. Create an observer.
107
106
  const observer = new Observer({
108
- // how often the observer aggregates call/client metrics:
109
- updatePolicy: 'update-on-interval',
110
- updateIntervalInMs: 5000,
107
+ // when the observer aggregates call/client metrics:
108
+ updatePolicy: 'update-when-all-call-updated',
111
109
  // default policy applied to calls created automatically by accept():
112
110
  defaultCallUpdatePolicy: 'update-on-any-client-updated',
113
111
  // optional auto-teardown:
@@ -178,7 +176,7 @@ client getStats() ──► ClientSample ──► observer.accept(sample, c
178
176
  |-------|-----------|------------------------|-------|
179
177
  | `Observer` | `new Observer(config?)` | — (root) | `observedCalls: Map<string, ObservedCall>`, global counters, the event bus |
180
178
  | `ObservedCall` | `observer.createObservedCall(settings)` / lazily by `accept` | `observedCalls` | `observedClients: Map<string, ObservedClient>`, call-wide metrics, `detectors`, `scoreCalculator` |
181
- | `ObservedClient` | `call.createObservedClient(settings)` / lazily | `observedClients` | `observedPeerConnections: Map<string, ObservedPeerConnection>`, per-client metrics, `report` |
179
+ | `ObservedClient` | `call.createObservedClient(settings)` / lazily | `observedClients` | `observedPeerConnections: Map<string, ObservedPeerConnection>`, per-client metrics |
182
180
  | `ObservedPeerConnection` | lazily, from `sample.peerConnections[]` | `observedPeerConnections` | the 15 sub-stat maps below, transport/RTT/bitrate metrics |
183
181
  | Sub-stats | lazily, from the `PeerConnectionSample` | maps on the PC | individual WebRTC stat objects |
184
182
 
@@ -207,12 +205,49 @@ fields from the schema plus derived fields (deltas, bitrates).
207
205
 
208
206
  The single entry point. It:
209
207
 
210
- 1. drops + emits `sample-rejected` if the observer is closed, or `callId`/`clientId` is missing;
211
- 2. gets or lazily creates the `ObservedCall` and `ObservedClient`;
212
- 3. shallow-merges `context` into the created/looked-up entity `appData`;
213
- 4. delegates to `client.accept(sample, context)`, which fans out to each
208
+ 1. drops + emits `sample-rejected` if the observer is closed;
209
+ 2. runs the sample through the **global accept-middleware chain** (see below);
210
+ 3. (chain terminal) drops + emits `sample-rejected` if `callId`/`clientId` is missing;
211
+ 4. gets or lazily creates the `ObservedCall` and `ObservedClient` (their `appData` comes from the
212
+ configured factories, never from `context`);
213
+ 5. delegates to `client.accept(sample, context)`, which fans out to each
214
214
  `ObservedPeerConnection.accept(pcSample, context)`.
215
215
 
216
+ ### Accept middlewares (global pre-dispatch hook)
217
+
218
+ `observer.addAcceptMiddleware(...)` registers middlewares run on **every** sample inside
219
+ `accept()`, in order, **before** the sample is dispatched to any call or client. Each middleware
220
+ gets a `{ sample, context }` payload; it can inspect or mutate the sample (set/normalize
221
+ `callId`/`clientId`, enrich, redact) or the context, then call `next(payload)` to continue.
222
+ **Not calling `next` drops the sample** — nothing is created and no event fires. A throwing
223
+ middleware is caught and warns (the sample is dropped), never crashing `accept()`.
224
+
225
+ ```ts
226
+ import { Observer, AcceptMiddleware } from '@observertc/observer-js';
227
+
228
+ const observer = new Observer();
229
+
230
+ // derive callId/clientId from the app's own attachment, before dispatch
231
+ const route: AcceptMiddleware = ({ sample }, next) => {
232
+ sample.callId ??= sample.attachments?.roomId as string;
233
+ sample.clientId ??= sample.attachments?.peerId as string;
234
+ next({ sample });
235
+ };
236
+
237
+ // drop samples from a blocklisted client (never dispatched)
238
+ const filter: AcceptMiddleware = (payload, next) => {
239
+ if (blocked.has(payload.sample.clientId)) return; // no next() => dropped
240
+ next(payload);
241
+ };
242
+
243
+ observer.addAcceptMiddleware(route, filter);
244
+ // observer.removeAcceptMiddleware(route);
245
+ ```
246
+
247
+ This is a lightweight global injection point, distinct from the larger (not-yet-built)
248
+ `ClientSampleProcessor` pipeline in the roadmap. When no middleware is registered, `accept()`
249
+ dispatches directly with no overhead.
250
+
216
251
  ### `context` (the `AcceptContext`)
217
252
 
218
253
  ```ts
@@ -260,9 +295,9 @@ These return `undefined` (and warn) when the parent is closed; `createObservedCa
260
295
  ## Update policies
261
296
 
262
297
  "Update" means *recompute aggregated metrics and emit the `*-updated` event* at that level.
263
- Both the observer and each call have a configurable trigger. The `update-on-interval` policy
264
- **requires** an interval and this is enforced at compile time (a discriminated union); at
265
- runtime a missing interval warns and falls back rather than throwing.
298
+ Both the observer and each call have a configurable trigger. Updates are **event-driven** — there
299
+ is no built-in timer. An app that wants a fixed cadence can call `observer.update()` /
300
+ `call.update()` from its own `setInterval`.
266
301
 
267
302
  **Observer-level** (`ObserverConfig.updatePolicy`, default `update-when-all-call-updated`):
268
303
 
@@ -270,7 +305,6 @@ runtime a missing interval warns and falls back rather than throwing.
270
305
  |--------|-------------------------------------|
271
306
  | `update-on-any-call-updated` | any call updates |
272
307
  | `update-when-all-call-updated` | every call has updated since the last observer update |
273
- | `update-on-interval` | a timer fires (`updateIntervalInMs` required) |
274
308
 
275
309
  **Call-level** (`ObservedCallSettings.updatePolicy`, defaulted from
276
310
  `ObserverConfig.defaultCallUpdatePolicy`):
@@ -279,7 +313,6 @@ runtime a missing interval warns and falls back rather than throwing.
279
313
  |--------|--------------------------------|
280
314
  | `update-on-any-client-updated` | any client in the call updates |
281
315
  | `update-when-all-client-updated` | every client has updated since the last call update |
282
- | `update-on-interval` | a timer fires (`updateIntervalInMs` required) |
283
316
 
284
317
  ---
285
318
 
@@ -354,7 +387,6 @@ additional field(s) on top of that scope.
354
387
  | `client-metadata` | `{ metaData: ClientMetaData }` | a client meta item arrived |
355
388
  | `client-extension-stats` | `{ extensionStats: ExtensionStat }` | an app-defined extension stat arrived |
356
389
  | `client-event` | `{ event: ClientEvent }` | any client event was processed |
357
- | `client-track-report` | `{ report: TrackReport }` | a track was removed; final per-track report |
358
390
 
359
391
  #### Peer-connection level — scope `{ observer, observedCall, observedClient, observedPeerConnection }`
360
392
 
@@ -404,12 +436,9 @@ listen to them, but prefer the bus equivalents above for application logic.
404
436
  ```ts
405
437
  new Observer<AppData>(config?: ObserverConfig<AppData>)
406
438
 
407
- type ObserverConfig<AppData = Record<string, unknown>> =
408
- ( { updatePolicy: 'update-on-interval'; updateIntervalInMs: number }
409
- | { updatePolicy?: 'update-on-any-call-updated' | 'update-when-all-call-updated'; updateIntervalInMs?: number }
410
- ) & {
439
+ type ObserverConfig<AppData = Record<string, unknown>> = {
440
+ updatePolicy?: 'update-on-any-call-updated' | 'update-when-all-call-updated';
411
441
  defaultCallUpdatePolicy?: ObservedCallSettings['updatePolicy'];
412
- defaultCallUpdateIntervalInMs?: number;
413
442
  appData?: AppData;
414
443
  closeClientIfIdleForMs?: number;
415
444
  closeCallIfEmptyForMs?: number;
@@ -419,6 +448,8 @@ type ObserverConfig<AppData = Record<string, unknown>> =
419
448
  createClientAppData?: (p: { clientId: string; observedCall: ObservedCall }) => Record<string, unknown>;
420
449
  // sink factory — produces a per-client sink that receives every accepted sample (see Sinks).
421
450
  createClientSink?: (p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined;
451
+ // track-resolver factory — produces a call's RemoteTrackResolver (see Remote track resolution).
452
+ createTrackResolver?: (observedCall: ObservedCall) => RemoteTrackResolver | undefined;
422
453
  };
423
454
  ```
424
455
 
@@ -439,6 +470,7 @@ const observer = new Observer({
439
470
  Key members:
440
471
 
441
472
  - `accept(sample: ClientSample, context?: AcceptContext): void`
473
+ - `addAcceptMiddleware(...mw: AcceptMiddleware[]): this` / `removeAcceptMiddleware(...mw): this` — global pre-dispatch sample hooks (see [Accept middlewares](#accept-middlewares-global-pre-dispatch-hook))
442
474
  - `getObservedCall<T>(callId): ObservedCall<T> | undefined`
443
475
  - `createObservedCall<T>(settings): ObservedCall<T> | undefined`
444
476
  - `getOrCreateObservedCall<T>(settings): ObservedCall<T> | undefined`
@@ -455,13 +487,10 @@ Key members:
455
487
  ### `ObservedCall`
456
488
 
457
489
  ```ts
458
- type ObservedCallSettings<AppData = Record<string, unknown>> =
459
- ( { updatePolicy: 'update-on-interval'; updateIntervalInMs: number }
460
- | { updatePolicy?: 'update-on-any-client-updated' | 'update-when-all-client-updated'; updateIntervalInMs?: number }
461
- ) & {
490
+ type ObservedCallSettings<AppData = Record<string, unknown>> = {
491
+ updatePolicy?: 'update-on-any-client-updated' | 'update-when-all-client-updated';
462
492
  callId: string;
463
493
  appData?: AppData;
464
- remoteTrackResolvePolicy?: 'p2p' | 'mediasoup-sfu' | 'none';
465
494
  closeCallIfEmptyForMs?: number;
466
495
  };
467
496
  ```
@@ -474,7 +503,7 @@ Key members:
474
503
  - `addIssue(issue: ClientIssue): void` — raise a **call-level** issue → emits `call-issue`
475
504
  - `readonly detectors: Detectors` — server-side detector registry (empty by default; see [Detectors](#detectors-server-side-extension-point))
476
505
  - `scoreCalculator: ScoreCalculator`, `get score()`, `readonly calculatedScore`
477
- - `remoteTrackResolver?: RemoteTrackResolver`
506
+ - `remoteTrackResolver?: RemoteTrackResolver` — set from `ObserverConfig.createTrackResolver` at call creation (see [Remote track resolution](#remote-track-resolution-mediasoup--sfu))
478
507
  - aggregates: `numberOfIssues`, `numberOfPeerConnections`, `numberOfInboundRtpStreams`,
479
508
  `numberOfOutboundRtpStreams`, `numberOfDataChannels`, `maxNumberOfClients`,
480
509
  `clientsUsedTurn: Set<string>`, `startedAt?`, `endedAt?`, `closedAt?`, `closed`
@@ -508,7 +537,6 @@ Key members:
508
537
  - Per-tick deltas: `deltaReceivedAudioBytes`, `deltaSentAudioBytes`, … (see source for the full set)
509
538
  - Lifecycle: `joinedAt?`, `leftAt?`, `closedAt?`, `closed`, `get score()`
510
539
  - Metadata: `browser?`, `engine?`, `platform?`, `operationSystem?`, `mediaDevices`, `mediaConstraints`
511
- - `readonly report: ClientReport` — cumulative report (byte/packet totals + RTT & score distributions)
512
540
  - `accept(sample, context?)`, `close()`
513
541
 
514
542
  ### `ObservedPeerConnection`
@@ -589,9 +617,6 @@ mediasoup set `PRODUCER_*` / `CONSUMER_*` / `DATA_PRODUCER_*` / `DATA_CONSUMER_*
589
617
  `MEDIA_DEVICES_SUPPORTED_CONSTRAINTS`, `USER_MEDIA_ERROR`, `LOCAL_SDP`, `OPERATION_SYSTEM`,
590
618
  `ENGINE`, `PLATFORM`, `BROWSER`.
591
619
 
592
- `Reports` exported: `ClientReport` (cumulative per-client totals + RTT/score distributions) and
593
- `TrackReport` (per-track final report, delivered on `client-track-report`).
594
-
595
620
  ---
596
621
 
597
622
  ## Detectors (server-side extension point)
@@ -644,29 +669,47 @@ class Detectors {
644
669
  ## Remote track resolution (mediasoup / SFU)
645
670
 
646
671
  In an SFU, one participant's **outbound** track is delivered to other participants as **inbound**
647
- tracks. To correlate them server-side, set `remoteTrackResolvePolicy: 'mediasoup-sfu'` on the
648
- call settings. The built-in `MediasoupRemoteTrackResolver` subscribes to the bus (filtered to
649
- its call) and maps producer↔consumer using track `attachments` (`producerId`, `consumerId`):
672
+ tracks (one **publisher** → many **subscribers**). Correlation is **opt-in** per observer: set
673
+ `ObserverConfig.createTrackResolver`, a factory invoked when each call is created that returns the
674
+ call's `RemoteTrackResolver` (or `undefined` for none).
675
+
676
+ `RemoteTrackResolver` is a generic, strategy-driven class. It subscribes to the bus (filtered to
677
+ its call) and links tracks by **publisher id** — the link key — maintaining the links directly on
678
+ the tracks: `inboundTrack.remoteOutboundTrack` and `outboundTrack.remoteInboundTracks: Set`.
650
679
 
651
680
  ```ts
652
- const call = observer.createObservedCall({ callId, remoteTrackResolvePolicy: 'mediasoup-sfu' });
653
- // later, given an inbound track:
654
- const source = inboundTrack.getRemoteOutboundTrack(); // the producing ObservedOutboundTrack
655
- const consumers = outboundTrack.getRemoteInboundTracks(); // the consuming ObservedInboundTrack[]
681
+ import { Observer, createDefaultMediasoupRemoteTrackResolverFactory } from '@observertc/observer-js';
682
+
683
+ const observer = new Observer({
684
+ createTrackResolver: createDefaultMediasoupRemoteTrackResolverFactory(),
685
+ });
686
+
687
+ // later, given tracks (links are kept up to date as tracks come and go):
688
+ const source = inboundTrack.remoteOutboundTrack; // the publishing ObservedOutboundTrack
689
+ const receivers = [ ...outboundTrack.remoteInboundTracks ]; // the subscribing ObservedInboundTrack[]
656
690
  ```
657
691
 
658
- Custom topologies can implement the `RemoteTrackResolver` interface and assign
659
- `call.remoteTrackResolver`:
692
+ Two built-in factories ship: `createDefaultMediasoupRemoteTrackResolverFactory()` (publisher =
693
+ `attachments.producerId`, subscriber = `attachments.consumerId`) and
694
+ `createP2pRemoteTrackResolverFactory()` (matches by RTP **SSRC**, preserved end-to-end in p2p).
695
+
696
+ For any other topology, build a `RemoteTrackResolver` with your own key resolvers — the publisher
697
+ id is just whatever links a subscribed track to the published one:
660
698
 
661
699
  ```ts
662
- interface RemoteTrackResolver {
663
- resolveRemoteOutboundTrack(inboundTrack: ObservedInboundTrack): ObservedOutboundTrack | undefined;
664
- resolveRemoteInboundTracks(outboundTrack: ObservedOutboundTrack): ObservedInboundTrack[] | undefined;
665
- }
700
+ import { Observer, RemoteTrackResolver } from '@observertc/observer-js';
701
+
702
+ const observer = new Observer({
703
+ createTrackResolver: (observedCall) => new RemoteTrackResolver(observedCall, {
704
+ resolveOutboundTrackPublisherId: (out) => out.attachments?.mediaId as string | undefined,
705
+ resolveInboundTrackPublisherId: (inb) => inb.attachments?.mediaId as string | undefined,
706
+ resolveInboundTrackSubscriberId: (inb) => inb.attachments?.subId as string | undefined, // optional
707
+ }),
708
+ });
666
709
  ```
667
710
 
668
- The application is expected to put `direction` (`'send'`/`'recv'`), `producerId`, `consumerId`,
669
- and `label` into `PeerConnectionSample.attachments` / track `attachments`.
711
+ For the mediasoup factory, the application puts `producerId` / `consumerId` (and optionally
712
+ `direction`, `label`) into the track `attachments`.
670
713
 
671
714
  ---
672
715
 
@@ -733,12 +776,32 @@ observer.on('client-sink-created', ({ observedClient, sink }) => {
733
776
  |--------|-----------|-------|
734
777
  | `createJsonlFileSinkFactory` | `({ directory, flags?, getFileName?, serializeSample? }) => ClientSampleSinkFactory` | per-client JSONL files; path defaults to `${callId}__${clientId}.jsonl` under `directory` (which **must exist**) |
735
778
  | `createJsonlFileSink` | `({ path, flags?, serializeSample? }) => ClientSampleSink` | a single JSONL file; wraps `fs.WriteStream` and re-emits its `close`/`finish`/`drain`/`error` |
736
- | `JsonlFileSink` | `class extends ClientSampleSink` | the underlying class, if you want to construct it directly |
779
+ | `JsonlFileSink` | `class extends ClientSampleSink` | the underlying class; exposes `readonly path` so a `close` handler knows which file is ready |
737
780
  | `createInMemorySink` / `InMemorySink` | `(samples?: ClientSample[]) => InMemorySink` | collects the accepted **sample objects** into `.samples: ClientSample[]`; emits `close` on `end()` |
738
781
 
739
782
  `serializeSample?: (sample: ClientSample) => string` overrides the default `JSON.stringify` for
740
783
  the JSONL sinks (e.g. to redact or reshape before writing).
741
784
 
785
+ ### Reading sink-specific info (e.g. the file path)
786
+
787
+ The bus hands you the sink as the base `ClientSampleSink`. To read information specific to a sink
788
+ type — for a file sink, where it was written — **narrow with `instanceof`** and read the sink's
789
+ public fields. `JsonlFileSink` exposes `path`:
790
+
791
+ ```ts
792
+ import { JsonlFileSink } from '@observertc/observer-js';
793
+
794
+ observer.on('client-sink-created', ({ observedClient, sink }) => {
795
+ if (sink instanceof JsonlFileSink) {
796
+ const { path } = sink; // the file this client's samples go to
797
+ sink.once('close', () => uploadFile(path)); // close = flushed & fd closed → ready
798
+ }
799
+ });
800
+ ```
801
+
802
+ The general pattern: each concrete sink exposes whatever it wants as `public readonly` fields, and
803
+ consumers narrow (`instanceof YourSink`) to read them. Your own sinks do the same.
804
+
742
805
  ### Writing your own sink
743
806
 
744
807
  Subclass `ClientSampleSink` and emit the lifecycle events yourself — for any non-file
@@ -800,14 +863,15 @@ filtering, per-module routing, and full silencing.
800
863
 
801
864
  The library **warns and degrades; it does not throw** on operational problems:
802
865
 
803
- - Bad config (`update-on-interval` without an interval) → warn + safe fallback policy.
804
866
  - `createObservedCall` / `createObservedClient` on a closed parent → warn + return `undefined`.
805
867
  - Duplicate id → warn + return the **existing** instance.
806
868
  - `accept()` on a closed client → warn + no-op.
807
869
  - Sample missing `callId`/`clientId`, or observer closed → `sample-rejected` event.
870
+ - A throwing accept-middleware → warn + drop that sample (never crashes `accept()`).
808
871
 
809
- Therefore `create*` and `getOrCreate*` return `T | undefined`; **guard the result.** The only
810
- remaining `throw`s are internal invariants in the unused `Middleware` utility.
872
+ Therefore `create*` and `getOrCreate*` return `T | undefined`; **guard the result.** The
873
+ `Middleware` utility's internal invariants (e.g. calling `next()` twice) throw, but those throws
874
+ are caught by `accept()` and surfaced as a warning.
811
875
 
812
876
  ---
813
877
 
@@ -844,7 +908,11 @@ export type { JsonlFileSinkOptions, JsonlFileSinkFactoryOptions } from './sinks/
844
908
  export { InMemorySink, createInMemorySink } from './sinks/InMemorySink';
845
909
 
846
910
  export { Middleware } from './common/Middleware';
847
- export type { TrackReport, ClientReport } from './Reports';
911
+
912
+ // remote track correlation
913
+ export { RemoteTrackResolver } from './utils/RemoteTrackResolver';
914
+ export type { RemoteTrackResolvers, RemoteTrackResolverFactory } from './utils/RemoteTrackResolver';
915
+ export { createDefaultMediasoupRemoteTrackResolverFactory, createP2pRemoteTrackResolverFactory } from './utils/RemoteTrackResolverFactories';
848
916
  ```
849
917
 
850
918
  ---
@@ -853,23 +921,23 @@ export type { TrackReport, ClientReport } from './Reports';
853
921
 
854
922
  ```bash
855
923
  yarn install
856
- yarn build # tsup → dist/ (ESM: index + sinks, with .d.mts types & sourcemaps)
924
+ yarn build # tsup → dist/ (dual ESM .mjs + CJS .js, single entry, .d.ts/.d.mts + sourcemaps)
857
925
  yarn lint # eslint -c .eslintrc.json "src/**/*.ts"
858
926
  yarn typecheck # tsc --noEmit
859
927
  yarn test # jest
860
928
  ```
861
929
 
862
930
  The build is driven by [`tsup`](https://tsup.egoist.dev) (config in `tsup.config.ts`): a single
863
- entry (`src/index.ts`), ESM output to `dist/` with `.d.mts` types and sourcemaps. CI
864
- (`.github/workflows/ci.yml`) runs lint + typecheck + **build** + test on every push/PR.
931
+ entry (`src/index.ts`), dual ESM + CommonJS output to `dist/` (`index.mjs` / `index.js`) with
932
+ `.d.mts` / `.d.ts` types and sourcemaps, targeting Node 22. CI (`.github/workflows/ci.yml`) runs
933
+ lint + typecheck + **build** + test on every push/PR.
865
934
 
866
935
  **Project layout** (`src/`): `Observer.ts`, `ObservedCall.ts`, `ObservedClient.ts`,
867
936
  `ObservedPeerConnection.ts`, the `Observed*` sub-stat classes, `ObserverEvents.ts` (the typed
868
937
  event map + scope types), `detectors/` (`Detector`, `Detectors`), `scores/`, `updaters/`
869
938
  (update-policy strategies), `utils/` (remote-track resolvers), `common/` (`logger`, `utils`,
870
- `Middleware`), `schema/` (sample/event/meta types), and `sinks/` (the import-safe
871
- `ClientSampleSink` base + the Node-only `JsonlFileSink` / `InMemorySink`, re-exported via the
872
- `@observertc/observer-js/sinks` subpath).
939
+ `Middleware`), `schema/` (sample/event/meta types), and `sinks/` (the `ClientSampleSink` base +
940
+ `JsonlFileSink` / `InMemorySink`, re-exported from the package root).
873
941
 
874
942
  **Conventions to follow when developing further:**
875
943