sdk implementation

This commit is contained in:
Timo K.
2026-10-08 13:53:56 +02:00
parent 9910496b90
commit 1a999eaa54
31 changed files with 3012 additions and 345 deletions
+52
View File
@@ -0,0 +1,52 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { Observable, Subject } from "rxjs";
import { LazyBehavior } from "./LazyBehavior";
describe("LazyBehavior", () => {
it("runs its source only while subscribed", () => {
const teardown = vi.fn();
let starts = 0;
const values$ = new Subject<number>();
const source$ = new Observable<number>((subscriber) => {
starts++;
const subscription = values$.subscribe(subscriber);
return (): void => {
teardown();
subscription.unsubscribe();
};
});
const lazy$ = new LazyBehavior(source$, 0);
expect(starts).toBe(0);
expect(lazy$.value).toBe(0);
const seenByFirst: number[] = [];
const first = lazy$.subscribe((v) => seenByFirst.push(v));
expect(starts).toBe(1);
values$.next(1);
expect(seenByFirst).toEqual([0, 1]);
expect(lazy$.value).toBe(1);
const seenBySecond: number[] = [];
const second = lazy$.subscribe((v) => seenBySecond.push(v));
expect(starts).toBe(1);
expect(seenBySecond).toEqual([1]);
first.unsubscribe();
expect(teardown).not.toHaveBeenCalled();
second.unsubscribe();
expect(teardown).toHaveBeenCalledTimes(1);
values$.next(2);
expect(lazy$.value).toBe(1);
lazy$.subscribe().unsubscribe();
expect(starts).toBe(2);
});
});
+53
View File
@@ -0,0 +1,53 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
BehaviorSubject,
type Observable,
type Subscriber,
type Subscription,
} from "rxjs";
/**
* A behavior that only runs its source while someone is subscribed. The source
* is subscribed with the first subscriber and unsubscribed with the last, so a
* value that is expensive to keep current, such as polled statistics, costs
* nothing while nobody looks at it. `value` is whatever the source last
* produced, or the initial value.
*/
export class LazyBehavior<T> extends BehaviorSubject<T> {
private subscribers = 0;
private upstream: Subscription | undefined;
public constructor(
private readonly source$: Observable<T>,
initialValue: T,
) {
super(initialValue);
}
// Every subscription of an Observable goes through this hook, which rxjs
// declares as internal and so leaves out of its types. The base class's
// version replays the current value and registers the subscriber; it is
// wrapped rather than replaced.
protected _subscribe(subscriber: Subscriber<T>): Subscription {
if (this.subscribers++ === 0)
this.upstream = this.source$.subscribe((value) => this.next(value));
const subscription = (
BehaviorSubject.prototype as unknown as {
_subscribe(this: BehaviorSubject<T>, s: Subscriber<T>): Subscription;
}
)._subscribe.call(this, subscriber);
subscription.add(() => {
if (--this.subscribers === 0) {
this.upstream?.unsubscribe();
this.upstream = undefined;
}
});
return subscription;
}
}
+35
View File
@@ -0,0 +1,35 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { RtcSessionError } from "../api";
import { toRtcSessionError } from "./errors";
describe("toRtcSessionError", () => {
it("keeps an RtcSessionError as it is", () => {
const error = new RtcSessionError("gone");
expect(toRtcSessionError(error)).toBe(error);
});
it("wraps another error as the cause", () => {
const cause = new Error("boom");
const error = toRtcSessionError(cause);
expect(error).toBeInstanceOf(RtcSessionError);
expect(error.message).toBe("boom");
expect(error.cause).toBe(cause);
});
it("falls back to the code of an error without a message", () => {
const cause = Object.assign(new Error(""), { code: "SFU_ERROR" });
expect(toRtcSessionError(cause).message).toBe("SFU_ERROR");
});
it("stringifies anything else", () => {
expect(toRtcSessionError("nope").message).toBe("nope");
});
});
+27
View File
@@ -0,0 +1,27 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { RtcSessionError } from "../api";
/** Wraps whatever a backend threw into the SDK's error type, keeping it as the cause. */
export function toRtcSessionError(error: unknown): RtcSessionError {
if (error instanceof RtcSessionError) return error;
if (error instanceof Error)
return new RtcSessionError(messageOf(error), { cause: error });
return new RtcSessionError(String(error));
}
/**
* Element Call's errors carry a translated title as their message, which is
* empty where no translations are loaded; their code says what went wrong
* regardless.
*/
function messageOf(error: Error): string {
if (error.message !== "") return error.message;
if ("code" in error && typeof error.code === "string") return error.code;
return error.name;
}
+37
View File
@@ -0,0 +1,37 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { ObservableScope } from "../../../src/state/ObservableScope";
import { mapScoped } from "./mapScoped";
describe("mapScoped", () => {
it("gives each value an item with a scope that ends with the value", () => {
const scope = new ObservableScope();
const source$ = new BehaviorSubject<string | null>("a");
const ended = vi.fn();
const items$ = mapScoped(scope, source$, (itemScope, value) => {
itemScope.onEnd(() => ended(value));
return value.toUpperCase();
});
expect(items$.value).toBe("A");
source$.next("b");
expect(items$.value).toBe("B");
expect(ended).toHaveBeenCalledWith("a");
source$.next(null);
expect(items$.value).toBeUndefined();
expect(ended).toHaveBeenCalledWith("b");
source$.next("c");
scope.end();
expect(ended).toHaveBeenCalledWith("c");
});
});
+35
View File
@@ -0,0 +1,35 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { Observable, of, switchMap } from "rxjs";
import { type Behavior } from "../../../src/state/Behavior";
import { ObservableScope } from "../../../src/state/ObservableScope";
/**
* Builds one item per present value of a behavior, each in a scope of its own
* that ends when the value changes or the outer scope ends. The item for a
* null or undefined value is undefined.
*/
export function mapScoped<T, Item>(
scope: ObservableScope,
source$: Behavior<T | null | undefined>,
factory: (scope: ObservableScope, value: T) => Item,
): Behavior<Item | undefined> {
return scope.behavior(
source$.pipe(
switchMap((value) => {
if (value === null || value === undefined) return of(undefined);
return new Observable<Item>((subscriber) => {
const itemScope = new ObservableScope();
subscriber.next(factory(itemScope, value));
return (): void => itemScope.end();
});
}),
),
);
}