@ksp-gonogo/kerbcast 1.4.0

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.
@@ -0,0 +1,606 @@
1
+ import { CameraLifecycle, ErrorSource, Layer, QualityPreset } from "../__generated__/types";
2
+ function buildCamera(init) {
3
+ return {
4
+ flightId: init.flightId,
5
+ lifecycle: init.lifecycle ?? CameraLifecycle.Active,
6
+ partName: init.partName ?? `part-${init.flightId}`,
7
+ partTitle: init.partTitle ?? `Part ${init.flightId}`,
8
+ cameraName: init.cameraName ?? `camera-${init.flightId}`,
9
+ vesselName: init.vesselName ?? "Test Vessel",
10
+ layers: init.layers ?? [Layer.Near],
11
+ operatorLayers: init.operatorLayers ?? [Layer.Near],
12
+ renderWidth: init.renderWidth ?? 1280,
13
+ renderHeight: init.renderHeight ?? 720,
14
+ operatorWidth: init.operatorWidth ?? 1280,
15
+ operatorHeight: init.operatorHeight ?? 720,
16
+ supportsZoom: init.supportsZoom ?? true,
17
+ fov: init.fov ?? 60,
18
+ fovMin: init.fovMin ?? 10,
19
+ fovMax: init.fovMax ?? 120,
20
+ supportsPan: init.supportsPan ?? false,
21
+ panYaw: init.panYaw ?? 0,
22
+ panPitch: init.panPitch ?? 0,
23
+ panYawMin: init.panYawMin ?? -90,
24
+ panYawMax: init.panYawMax ?? 90,
25
+ panPitchMin: init.panPitchMin ?? -90,
26
+ panPitchMax: init.panPitchMax ?? 90,
27
+ encoderBitrateBps: init.encoderBitrateBps ?? 0,
28
+ targetBitrateBps: init.targetBitrateBps ?? 0,
29
+ degradeLevel: init.degradeLevel ?? 0,
30
+ viewerQuality: init.viewerQuality,
31
+ qualityLimitedBy: init.qualityLimitedBy,
32
+ };
33
+ }
34
+ /** Fraction of the operator render size each preset targets (mirrors the
35
+ * sidecar's `QualityPreset::scale`). */
36
+ const QUALITY_PRESET_SCALE = {
37
+ [QualityPreset.Full]: 1.0,
38
+ [QualityPreset.ThreeQuarter]: 0.75,
39
+ [QualityPreset.Half]: 0.5,
40
+ [QualityPreset.Quarter]: 0.25,
41
+ };
42
+ /** Scale + floor-to-even like the plugin's QualityClamp.ScaleDimension. */
43
+ function scaleDim(operatorDim, scale) {
44
+ const v = Math.trunc(operatorDim * scale) & ~1;
45
+ return v < 2 ? 2 : v;
46
+ }
47
+ /**
48
+ * In-process protocol-level fake for the kerbcast sidecar.
49
+ *
50
+ * Owns a camera registry and speaks the full kerbcast wire protocol.
51
+ * Use it in tests to exercise `KerbcastClient` behaviour without a real
52
+ * sidecar or WebRTC stack.
53
+ *
54
+ * ```ts
55
+ * const sidecar = new MockSidecar();
56
+ * sidecar.addCamera({ flightId: 42 });
57
+ *
58
+ * vi.spyOn(globalThis, "fetch").mockImplementation(() =>
59
+ * Promise.resolve(MockSidecar.makeOfferResponse([42]))
60
+ * );
61
+ *
62
+ * const client = new KerbcastClient({ host: "localhost", port: 8088 }, sidecar.createTransport());
63
+ * await client.connect([42]);
64
+ * sidecar.open(); // fires hello + camera-snapshot
65
+ *
66
+ * expect(client.cameras[0].flightId).toBe(42);
67
+ * ```
68
+ */
69
+ export class MockSidecar {
70
+ constructor() {
71
+ this._cameras = new Map();
72
+ this._commands = [];
73
+ this._throttleMainScreen = false;
74
+ this._trackIdx = 0;
75
+ /** Slot mids available for the dynamic-subscription model. Override with
76
+ * {@link withSlots} before connecting if a test needs a specific pool. */
77
+ this._slotMids = ["0", "1", "2", "3"];
78
+ /** mid -> camera currently bound to that slot. */
79
+ this._slotBindings = new Map();
80
+ /**
81
+ * Per-flight inbound stats to return from the fake getStats. Set via
82
+ * {@link setInboundStats}. Keyed by flightId.
83
+ */
84
+ this._inboundStats = new Map();
85
+ /**
86
+ * Tracks delivered by {@link deliverTrack}, keyed by mid/idx string so
87
+ * getStats can synthesize a trackIdentifier matching what the client saw.
88
+ */
89
+ this._deliveredTracks = new Map();
90
+ }
91
+ /** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
92
+ addCamera(init) {
93
+ this._cameras.set(init.flightId, buildCamera(init));
94
+ }
95
+ /**
96
+ * Returns a `KerbcastTransport` backed by this mock. Pass it as the
97
+ * second argument to `KerbcastClient`.
98
+ */
99
+ createTransport() {
100
+ const self = this;
101
+ return {
102
+ createPeer() {
103
+ const channel = {
104
+ send(payload) {
105
+ const msg = JSON.parse(payload);
106
+ self._commands.push(msg);
107
+ self._handleClientMessage(msg);
108
+ },
109
+ onOpen(h) {
110
+ self._openHandler = h;
111
+ },
112
+ onMessage(h) {
113
+ self._clientMsgHandler = h;
114
+ },
115
+ onClose() { },
116
+ };
117
+ return {
118
+ addRecvOnlyTransceiver() { },
119
+ createDataChannel: () => channel,
120
+ onTrack(h) {
121
+ self._onTrackHandler = h;
122
+ },
123
+ onStateChange(h) {
124
+ self._stateHandler = h;
125
+ },
126
+ createOffer: async () => "v=0\r\n",
127
+ setLocalDescription: async () => { },
128
+ setRemoteAnswer: async () => { },
129
+ waitForIceComplete: async () => { },
130
+ localSdp: () => "v=0\r\n",
131
+ close() { },
132
+ getStats: async () => self._buildStatsReport(),
133
+ };
134
+ },
135
+ };
136
+ }
137
+ /**
138
+ * Simulate the sidecar completing the WebRTC handshake. Fires the
139
+ * channel `onOpen` handler (which triggers the client's `hello`), then
140
+ * responds with `hello` + `camera-snapshot`.
141
+ */
142
+ open() {
143
+ this._openHandler?.();
144
+ this._sendToClient({ type: "hello", content: { sidecarVersion: "0.0.1-mock", encoderBackend: "mock" } });
145
+ this._sendToClient({ type: "camera-snapshot", content: { cameras: Array.from(this._cameras.values()) } });
146
+ this._sendToClient({ type: "settings-state", content: { throttleMainScreen: this._throttleMainScreen } });
147
+ }
148
+ /** Drive the underlying peer's connection-state handler. */
149
+ setConnectionState(state) {
150
+ this._stateHandler?.(state);
151
+ }
152
+ /**
153
+ * Mark a camera as destroyed and push a `camera-state-changed` message
154
+ * to the client. The camera stays in the internal registry with
155
+ * `lifecycle: Destroyed`.
156
+ */
157
+ destroyCamera(flightId) {
158
+ const cam = this._cameras.get(flightId);
159
+ if (!cam)
160
+ return;
161
+ const destroyed = { ...cam, lifecycle: CameraLifecycle.Destroyed };
162
+ this._cameras.set(flightId, destroyed);
163
+ this._sendToClient({ type: "camera-state-changed", content: { state: destroyed } });
164
+ }
165
+ /**
166
+ * Apply a partial update to an existing camera and push a
167
+ * `camera-state-changed` message to the client.
168
+ */
169
+ updateCamera(flightId, partial) {
170
+ const cam = this._cameras.get(flightId);
171
+ if (!cam)
172
+ return;
173
+ const updated = { ...cam, ...partial };
174
+ this._cameras.set(flightId, updated);
175
+ this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
176
+ }
177
+ /**
178
+ * Replace the entire camera registry and push a fresh `camera-snapshot` to
179
+ * the client. Models a vessel change / scene switch where the set of
180
+ * available cameras changes (cameras appear or disappear) — distinct from
181
+ * {@link destroyCamera}, which keeps the camera present but `Destroyed`.
182
+ */
183
+ setCameras(inits) {
184
+ this._cameras.clear();
185
+ for (const init of inits) {
186
+ this._cameras.set(init.flightId, buildCamera(init));
187
+ }
188
+ this._sendToClient({
189
+ type: "camera-snapshot",
190
+ content: { cameras: Array.from(this._cameras.values()) },
191
+ });
192
+ }
193
+ /** Send a `ping` from the sidecar; the client should respond with `pong`. */
194
+ firePing() {
195
+ this._sendToClient({ type: "ping" });
196
+ }
197
+ /** Push an `adaptive-shed` event to the client. */
198
+ fireAdaptiveShed(payload) {
199
+ this._sendToClient({ type: "adaptive-shed", content: payload });
200
+ }
201
+ /** Push an `error` event to the client (simulates a sidecar error reply). */
202
+ fireError(payload) {
203
+ this._sendToClient({ type: "error", content: payload });
204
+ }
205
+ /** Current mock-sidecar throttle state (reflects `set-throttle-main-screen` commands). */
206
+ get throttleMainScreen() {
207
+ return this._throttleMainScreen;
208
+ }
209
+ /** Push a `settings-state` event to the client (simulates a plugin-status-change broadcast). */
210
+ fireSettingsState(payload) {
211
+ this._throttleMainScreen = payload.throttleMainScreen;
212
+ this._sendToClient({ type: "settings-state", content: payload });
213
+ }
214
+ /** Configure the slot-pool mids before connecting (dynamic mode). */
215
+ withSlots(mids) {
216
+ this._slotMids = [...mids];
217
+ return this;
218
+ }
219
+ /**
220
+ * Deliver a track onto a slot (by mid), simulating the slot's media
221
+ * arriving over WebRTC. The client routes it to whichever camera is bound
222
+ * to that mid. (jsdom can't make real tracks; pass a stub in unit tests or
223
+ * a `canvas.captureStream()` track in a real-browser harness.)
224
+ *
225
+ * The track is remembered so the fake `getStats()` can synthesize a
226
+ * `trackIdentifier` matching what the client received.
227
+ */
228
+ deliverTrack(mid, track) {
229
+ this._deliveredTracks.set(mid, track);
230
+ this._onTrackHandler?.(track, this._trackIdx++, mid);
231
+ }
232
+ /** The slot mid currently carrying `flightId`, or undefined. */
233
+ slotMidFor(flightId) {
234
+ for (const [mid, fid] of this._slotBindings) {
235
+ if (fid === flightId)
236
+ return mid;
237
+ }
238
+ return undefined;
239
+ }
240
+ /** Every `ClientMessage` received from the client, in order. */
241
+ get commands() {
242
+ return this._commands;
243
+ }
244
+ /**
245
+ * Find the most recent client command of the given type. Pass `flightId`
246
+ * to further filter by camera (ignored for message types without a
247
+ * `content.flightId` field).
248
+ */
249
+ lastCommand(type, flightId) {
250
+ for (let i = this._commands.length - 1; i >= 0; i--) {
251
+ const cmd = this._commands[i];
252
+ if (cmd.type !== type)
253
+ continue;
254
+ if (flightId !== undefined) {
255
+ const c = cmd;
256
+ if (c.content?.flightId !== flightId)
257
+ continue;
258
+ }
259
+ return cmd;
260
+ }
261
+ return undefined;
262
+ }
263
+ /**
264
+ * Build a `Response` that looks like the sidecar's `POST /offer` reply.
265
+ * Pass to `vi.spyOn(globalThis, "fetch").mockImplementation(...)` so the
266
+ * client's handshake can complete without a real HTTP server.
267
+ *
268
+ * ```ts
269
+ * vi.spyOn(globalThis, "fetch").mockImplementation(() =>
270
+ * Promise.resolve(MockSidecar.makeOfferResponse([42]))
271
+ * );
272
+ * ```
273
+ */
274
+ static makeOfferResponse(cameras) {
275
+ return new Response(JSON.stringify({ sdp: "v=0\r\n", cameras }), { status: 200, headers: { "Content-Type": "application/json" } });
276
+ }
277
+ /**
278
+ * Signaling-seam analogue of {@link makeOfferResponse}: resolve an offer's
279
+ * answer without HTTP. Pass as the client's `negotiate` config to exercise
280
+ * the brokered-signaling path a station uses.
281
+ */
282
+ negotiate(offer) {
283
+ return Promise.resolve({ sdp: "v=0\r\n", cameras: offer.cameras });
284
+ }
285
+ /**
286
+ * Configure the inbound video stats the fake `getStats()` will return for
287
+ * a given camera. Call before `client.inboundVideoStats()` in tests.
288
+ *
289
+ * The mock synthesizes a minimal `RTCStatsReport`-shaped object: one entry
290
+ * per flight that has stats set, with `type: "inbound-rtp"`, `kind: "video"`,
291
+ * and either a `trackIdentifier` matching the track delivered for that camera
292
+ * (legacy path) or a `mid` matching the slot binding (dynamic path), plus
293
+ * the stat fields from `partialStats`.
294
+ *
295
+ * ```ts
296
+ * sidecar.setInboundStats(42, { packetsReceived: 1000, framesDecoded: 300 });
297
+ * const stats = await client.inboundVideoStats();
298
+ * expect(stats.get(42)?.packetsReceived).toBe(1000);
299
+ * ```
300
+ */
301
+ setInboundStats(flightId, partialStats) {
302
+ this._inboundStats.set(flightId, partialStats);
303
+ }
304
+ /** Build a minimal RTCStatsReport-compatible object for the current state. */
305
+ _buildStatsReport() {
306
+ const entries = [];
307
+ for (const [flightId, stats] of this._inboundStats) {
308
+ const id = `inbound-rtp-${flightId}`;
309
+ // Resolve the identifier: prefer trackIdentifier from the delivered track
310
+ // (legacy path); fall back to mid from the slot binding (dynamic path).
311
+ let trackIdentifier;
312
+ let mid;
313
+ // Check slot bindings (dynamic mode).
314
+ for (const [slotMid, fid] of this._slotBindings) {
315
+ if (fid === flightId) {
316
+ mid = slotMid;
317
+ const track = this._deliveredTracks.get(slotMid);
318
+ if (track?.id)
319
+ trackIdentifier = track.id;
320
+ break;
321
+ }
322
+ }
323
+ // Legacy mode: look for a delivered track whose mid matches the index
324
+ // position (mids in legacy mode are the slot index strings "0", "1", ...).
325
+ if (!trackIdentifier && !mid) {
326
+ for (const [deliveredMid, track] of this._deliveredTracks) {
327
+ if (track.id) {
328
+ trackIdentifier = track.id;
329
+ mid = deliveredMid;
330
+ break;
331
+ }
332
+ }
333
+ }
334
+ const entry = {
335
+ id,
336
+ type: "inbound-rtp",
337
+ timestamp: Date.now(),
338
+ kind: "video",
339
+ trackIdentifier,
340
+ mid,
341
+ packetsReceived: stats.packetsReceived ?? 0,
342
+ bytesReceived: stats.bytesReceived ?? 0,
343
+ framesReceived: stats.framesReceived,
344
+ framesDecoded: stats.framesDecoded,
345
+ jitter: stats.jitter,
346
+ framesPerSecond: stats.framesPerSecond,
347
+ };
348
+ entries.push([id, entry]);
349
+ }
350
+ // Build a Map that satisfies the RTCStatsReport interface (iterable + forEach).
351
+ const map = new Map(entries);
352
+ return map;
353
+ }
354
+ _sendToClient(msg) {
355
+ this._clientMsgHandler?.(JSON.stringify(msg));
356
+ }
357
+ _handleClientMessage(msg) {
358
+ switch (msg.type) {
359
+ case "set-fov": {
360
+ const cam = this._cameras.get(msg.content.flightId);
361
+ if (cam)
362
+ this._cameras.set(msg.content.flightId, { ...cam, fov: msg.content.fov });
363
+ break;
364
+ }
365
+ case "set-layers": {
366
+ const cam = this._cameras.get(msg.content.flightId);
367
+ if (cam) {
368
+ this._cameras.set(msg.content.flightId, {
369
+ ...cam,
370
+ layers: msg.content.layers,
371
+ operatorLayers: msg.content.layers,
372
+ });
373
+ }
374
+ break;
375
+ }
376
+ case "set-render-size": {
377
+ const cam = this._cameras.get(msg.content.flightId);
378
+ if (cam) {
379
+ this._cameras.set(msg.content.flightId, {
380
+ ...cam,
381
+ renderWidth: msg.content.width,
382
+ renderHeight: msg.content.height,
383
+ operatorWidth: msg.content.width,
384
+ operatorHeight: msg.content.height,
385
+ });
386
+ }
387
+ break;
388
+ }
389
+ case "set-pan": {
390
+ const cam = this._cameras.get(msg.content.flightId);
391
+ if (cam) {
392
+ this._cameras.set(msg.content.flightId, {
393
+ ...cam,
394
+ panYaw: msg.content.yaw,
395
+ panPitch: msg.content.pitch,
396
+ });
397
+ }
398
+ break;
399
+ }
400
+ case "set-degrade": {
401
+ const cam = this._cameras.get(msg.content.flightId);
402
+ if (cam) {
403
+ this._cameras.set(msg.content.flightId, { ...cam, degradeLevel: msg.content.level });
404
+ }
405
+ break;
406
+ }
407
+ case "set-quality": {
408
+ // Models the request being honored (no adaptive throttle in the
409
+ // mock): effective dims become the preset's fraction of the
410
+ // operator ceiling, and the authoritative state is echoed back as
411
+ // camera-state-changed, the same broadcast the sidecar fans out
412
+ // to every peer. Use `updateCamera` to simulate the throttled case
413
+ // (renderWidth below the target + qualityLimitedBy: "throttled").
414
+ const cam = this._cameras.get(msg.content.flightId);
415
+ if (!cam) {
416
+ this._sendToClient({
417
+ type: "error",
418
+ content: {
419
+ message: `no camera with flight_id=${msg.content.flightId}`,
420
+ source: ErrorSource.Sidecar,
421
+ },
422
+ });
423
+ break;
424
+ }
425
+ const preset = msg.content.preset ?? undefined;
426
+ const scale = preset ? QUALITY_PRESET_SCALE[preset] : 1.0;
427
+ const updated = {
428
+ ...cam,
429
+ viewerQuality: preset,
430
+ qualityLimitedBy: undefined,
431
+ renderWidth: scaleDim(cam.operatorWidth, scale),
432
+ renderHeight: scaleDim(cam.operatorHeight, scale),
433
+ };
434
+ this._cameras.set(msg.content.flightId, updated);
435
+ this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
436
+ break;
437
+ }
438
+ case "subscribe": {
439
+ const flightId = msg.content.flightId;
440
+ const freeMid = this._slotMids.find((m) => !this._slotBindings.has(m));
441
+ if (freeMid === undefined) {
442
+ this._sendToClient({
443
+ type: "error",
444
+ content: { message: "no free slot", source: ErrorSource.Sidecar },
445
+ });
446
+ }
447
+ else {
448
+ this._slotBindings.set(freeMid, flightId);
449
+ this._sendToClient({
450
+ type: "slot-map",
451
+ content: { mid: freeMid, flightId },
452
+ });
453
+ }
454
+ break;
455
+ }
456
+ case "unsubscribe": {
457
+ const flightId = msg.content.flightId;
458
+ let bound;
459
+ for (const [mid, fid] of this._slotBindings) {
460
+ if (fid === flightId) {
461
+ bound = mid;
462
+ break;
463
+ }
464
+ }
465
+ if (bound !== undefined) {
466
+ this._slotBindings.delete(bound);
467
+ this._sendToClient({
468
+ type: "slot-map",
469
+ content: { mid: bound, flightId: undefined },
470
+ });
471
+ }
472
+ break;
473
+ }
474
+ // Persistent velocities are integrated frame-by-frame by the plugin;
475
+ // the mock has no frame clock, so it doesn't model their *effect* on
476
+ // panYaw/fov. They're still recorded in `_commands`, so consumer tests
477
+ // can assert the command was sent via `lastCommand("set-pan-rate")`.
478
+ case "set-pan-rate":
479
+ case "set-zoom-rate":
480
+ case "hello":
481
+ case "pong":
482
+ case "request-keyframe":
483
+ break;
484
+ case "set-throttle-main-screen": {
485
+ /* Flip state and echo SettingsState back, mirroring the sidecar's broadcast. */
486
+ this._throttleMainScreen = msg.content.enabled;
487
+ this._sendToClient({
488
+ type: "settings-state",
489
+ content: { throttleMainScreen: this._throttleMainScreen },
490
+ });
491
+ break;
492
+ }
493
+ case "disconnect":
494
+ break;
495
+ }
496
+ }
497
+ }
498
+ /**
499
+ * Install jsdom shims needed by kerbcast component tests.
500
+ *
501
+ * jsdom omits several browser APIs that the SDK and React components call at
502
+ * construction or mount time. Stubbing here keeps individual tests clean.
503
+ * Each shim is idempotent so setup files can call `installDomStubs()`
504
+ * unconditionally.
505
+ *
506
+ * Stubs installed:
507
+ * ResizeObserver - jsdom does not implement it; CameraFeed uses it to
508
+ * drive auto render-size updates.
509
+ * captureStream - jsdom's HTMLCanvasElement lacks captureStream; the
510
+ * noise pipeline calls it to get its output MediaStream.
511
+ * Returns a stub MediaStream so callers receive a valid
512
+ * object rather than throwing.
513
+ * MediaStream - jsdom's MediaStream constructor is incomplete; tests
514
+ * need to construct instances for track delivery and
515
+ * stream assertions.
516
+ * play - jsdom prints "Not implemented" for HTMLMediaElement.play;
517
+ * the noise pipeline awaits it on the internal video element.
518
+ * matchMedia - jsdom does not implement window.matchMedia; theme
519
+ * detection reads prefers-color-scheme through it.
520
+ */
521
+ export function installDomStubs() {
522
+ // ResizeObserver: jsdom omits entirely; CameraFeed's auto-size hook needs it.
523
+ if (typeof globalThis.ResizeObserver === "undefined") {
524
+ globalThis.ResizeObserver = class ResizeObserver {
525
+ observe() { }
526
+ unobserve() { }
527
+ disconnect() { }
528
+ };
529
+ }
530
+ // captureStream: jsdom's HTMLCanvasElement does not have it; the noise
531
+ // pipeline's tryCreateNoisePipeline checks for it and returns null when
532
+ // absent (safe degrade), but component tests that exercise the stream
533
+ // path directly need a stub that returns a constructible MediaStream.
534
+ if (typeof HTMLCanvasElement !== "undefined" &&
535
+ typeof HTMLCanvasElement.prototype.captureStream !== "function") {
536
+ HTMLCanvasElement.prototype.captureStream =
537
+ (_fps) => new StubMediaStream();
538
+ }
539
+ // MediaStream: jsdom's implementation is minimal and not always constructible
540
+ // with tracks. Provide a stub that satisfies the basic interface used by
541
+ // tests (getTracks, addTrack, id).
542
+ installStubMediaStream();
543
+ // play: jsdom's HTMLMediaElement.play is not implemented and prints a warning.
544
+ // The noise pipeline awaits video.play() on its internal video element.
545
+ if (typeof HTMLMediaElement !== "undefined") {
546
+ HTMLMediaElement.prototype.play = () => Promise.resolve();
547
+ }
548
+ // matchMedia: jsdom does not implement window.matchMedia; theme detection
549
+ // queries prefers-color-scheme through it. Stub returns a non-matching
550
+ // MediaQueryList so tests default to the light theme unless overridden.
551
+ if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
552
+ window.matchMedia = (query) => ({
553
+ matches: false,
554
+ media: query,
555
+ onchange: null,
556
+ addListener() { },
557
+ removeListener() { },
558
+ addEventListener() { },
559
+ removeEventListener() { },
560
+ dispatchEvent: () => false,
561
+ });
562
+ }
563
+ }
564
+ /** Minimal MediaStream stand-in for jsdom environments. */
565
+ class StubMediaStream {
566
+ constructor() {
567
+ this.id = Math.random().toString(36).slice(2);
568
+ this._tracks = [];
569
+ }
570
+ getTracks() {
571
+ return [...this._tracks];
572
+ }
573
+ getVideoTracks() {
574
+ return this._tracks.filter((t) => t.kind === "video");
575
+ }
576
+ getAudioTracks() {
577
+ return this._tracks.filter((t) => t.kind === "audio");
578
+ }
579
+ addTrack(track) {
580
+ this._tracks.push(track);
581
+ }
582
+ removeTrack(track) {
583
+ const i = this._tracks.indexOf(track);
584
+ if (i !== -1)
585
+ this._tracks.splice(i, 1);
586
+ }
587
+ clone() {
588
+ return new StubMediaStream();
589
+ }
590
+ }
591
+ function installStubMediaStream() {
592
+ if (typeof globalThis === "undefined")
593
+ return;
594
+ // Only replace if the native MediaStream is not constructible (jsdom
595
+ // registers the class but its constructor requires active getUserMedia
596
+ // permissions that are never granted in a test environment).
597
+ try {
598
+ const ms = new globalThis.MediaStream();
599
+ if (typeof ms.getTracks === "function")
600
+ return; // native is adequate
601
+ }
602
+ catch {
603
+ // Fall through to install the stub.
604
+ }
605
+ globalThis.MediaStream = StubMediaStream;
606
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@ksp-gonogo/kerbcast",
3
+ "version": "1.4.0",
4
+ "description": "TypeScript client for the kerbcast KSP camera-streaming sidecar. KerbcastClient wraps the WebRTC peer + control channel; wire-format types are typeshare-generated from the sidecar's Rust protocol module.",
5
+ "license": "CC-BY-NC-SA-4.0",
6
+ "author": "Jon Pepler",
7
+ "homepage": "https://github.com/ksp-gonogo/kerbcast#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/ksp-gonogo/kerbcast/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ksp-gonogo/kerbcast.git",
14
+ "directory": "client-sdk/typescript"
15
+ },
16
+ "keywords": [
17
+ "kerbcast",
18
+ "ksp",
19
+ "kerbal-space-program",
20
+ "webrtc",
21
+ "h264",
22
+ "protocol",
23
+ "typeshare"
24
+ ],
25
+ "main": "dist/index.js",
26
+ "types": "dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./testing": {
33
+ "types": "./dist/testing/index.d.ts",
34
+ "default": "./dist/testing/index.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist/",
39
+ "src/",
40
+ "README.md",
41
+ "CHANGELOG.md"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "devDependencies": {
47
+ "jsdom": "^29.0.2",
48
+ "typescript": "^5.4.0",
49
+ "vitest": "^4.1.4"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc",
53
+ "test": "vitest run",
54
+ "typecheck": "tsc --noEmit",
55
+ "regen": "typeshare ../../sidecar/src --lang=typescript --output-file=src/__generated__/types.ts"
56
+ }
57
+ }