Initial Commit

This commit is contained in:
2025-08-16 14:17:46 -04:00
commit 651a21a035
49 changed files with 1347 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
export default function assertUnreachable(never: never): never {
throw new Error(`Should not have reached [${never}]`);
}

3
src/assertions/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import assertUnreachable from './assertUnreachable';
export {assertUnreachable};

View File

@@ -0,0 +1,23 @@
import type IDisposable from './IDisposable';
export default class Disposable implements IDisposable {
private _isDisposed = false;
private _disposable: () => void;
constructor(disposable: () => void) {
this._disposable = disposable;
}
get isDisposed(): boolean {
return this._isDisposed;
}
public dispose(): void {
if (this._isDisposed) {
return;
}
this._disposable();
this._isDisposed = true;
}
}

View File

@@ -0,0 +1,17 @@
import type IDisposable from './IDisposable';
export default class DisposableList implements IDisposable {
private readonly _disposables: IDisposable[] = [];
public add(disposable: IDisposable): void {
this._disposables.push(disposable);
}
public dispose(): void {
while (this._disposables.length) {
const disposable = this._disposables.shift() as IDisposable;
disposable.dispose();
}
}
}

View File

@@ -0,0 +1,3 @@
export default interface IDisposable {
dispose(): void;
}

6
src/disposables/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import type IDisposable from './IDisposable';
import Disposable from './Disposable';
import DisposableList from './DisposableList';
export type {IDisposable};
export {Disposable, DisposableList};

View File

@@ -0,0 +1,23 @@
import type IDisposable from '../disposables/IDisposable';
import type IEvent from './IEvent';
import {Disposable} from '../disposables';
export default class EventEmitter<T> implements IDisposable {
private readonly _listeners: Array<(event: IEvent<T>) => void> = [];
public subscribe(listener: (event: IEvent<T>) => void): Disposable {
const listenerIndex = this._listeners.push(listener);
return new Disposable(() => this._listeners.splice(listenerIndex - 1, 1));
}
public emit<Event extends IEvent<T>>(event: Event): void {
this._listeners.forEach(listener => listener(event));
}
public dispose(): void {
while (this._listeners.length) {
this._listeners.shift();
}
}
}

View File

@@ -0,0 +1,39 @@
import type IEvent from './IEvent';
import {Disposable, DisposableList} from '../disposables';
import EventEmitter from './EventEmitter';
export default class EventPublisher {
private readonly _eventEmitters: Record<string, EventEmitter<any>> = {};
private readonly _disposables = new DisposableList();
constructor() {
this._eventEmitters['no-subscribers'] = new EventEmitter();
}
public addNoSubscriberHandler(handler: (event: IEvent<any>) => Promise<void> | void) {
return this._eventEmitters['no-subscribers'].subscribe(handler);
}
public subscribe<T>(event: string | number, handler: (event: IEvent<T>) => Promise<void> | void): Disposable {
const emitter = this._eventEmitters[event] ?? this.createEmitter<T>(event);
const subscription = emitter.subscribe(handler);
this._disposables.add(subscription);
return subscription;
}
public publish<T>(eventName: string | number, event: IEvent<T>): void {
(this._eventEmitters[eventName] || this._eventEmitters['no-subscribers']).emit<IEvent<T>>(event);
}
public dispose(): void {
Object.values(this._eventEmitters).forEach(emitter => emitter.dispose());
}
private createEmitter<T>(event: string | number): EventEmitter<IEvent<T>> {
const eventEmitter = new EventEmitter<IEvent<T>>();
return (this._eventEmitters[event] = eventEmitter);
}
}

4
src/events/IEvent.ts Normal file
View File

@@ -0,0 +1,4 @@
export default interface IEvent<Payload> {
type: string;
payload?: Payload;
}

6
src/events/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import type IEvent from './IEvent';
import EventEmitter from './EventEmitter';
import EventPublisher from './EventPublisher';
export type {IEvent};
export {EventEmitter, EventPublisher};

3
src/functions/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import {createRangeIterator} from './rangeIterator';
export {createRangeIterator};

View File

@@ -0,0 +1,16 @@
export function createRangeIterator(start: number, end: number, step = 1) {
return {
[Symbol.iterator]() {
return this;
},
next() {
if (start < end) {
start = start + step;
return {value: start, done: false};
}
return {value: end, done: true};
}
};
}

12
src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import type IDisposable from './disposables/IDisposable';
import type IEvent from './events/IEvent';
export type {IDisposable, IEvent};
export {assertUnreachable} from './assertions';
export {Disposable, DisposableList} from './disposables';
export {EventEmitter, EventPublisher} from './events';
export {Averager, Random} from './maths';
export {Subject, ReadOnlySubject} from './observables';
export {Strings} from './strings';
export {createRangeIterator} from './functions';

33
src/maths/Averager.ts Normal file
View File

@@ -0,0 +1,33 @@
import {Subject, ReadOnlySubject} from '../observables';
export default class Averager {
private readonly _sampleSize: number;
private readonly _samples: number[] = [];
private readonly _average = new Subject<number>(0);
private readonly _readOnlyAverage = new ReadOnlySubject<number>(this._average);
private _onPush: (sample: number) => void = this.onPushOnly.bind(this);
constructor(sampleSize: number) {
this._sampleSize = sampleSize;
}
get average(): ReadOnlySubject<number> {
return this._readOnlyAverage;
}
public push(value: number): void {
this._onPush(value);
}
private onPushOnly(sample: number) {
if (this._samples.push(sample) === this._sampleSize) {
this._onPush = this.pushAndCalculateAverage.bind(this);
}
}
private pushAndCalculateAverage(sample: number) {
this._samples.push(sample);
this._samples.shift();
this._average.value = this._samples.reduce((sum, sampleValue) => sum + sampleValue, 0) / this._sampleSize;
}
}

23
src/maths/Random.ts Normal file
View File

@@ -0,0 +1,23 @@
export default class Random {
private static readonly lowercaseLetters = 'abcdefghijklmnopqrstuvwxyz';
private static readonly uppercaseLatters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
private static readonly letters = this.lowercaseLetters + this.uppercaseLatters;
public static number(): number {
return Math.random();
}
public static numberInRange(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
public static string(length: number): string {
let result = '';
for (let idx = 0; idx < length; idx++) {
result += this.letters[this.numberInRange(0, this.letters.length - 1)];
}
return result;
}
}

4
src/maths/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import Averager from './Averager';
import Random from './Random';
export {Averager, Random};

View File

@@ -0,0 +1,18 @@
import Disposable from '../disposables/Disposable';
import Subject from './Subject';
export default class ReadOnlySubject<T> {
private readonly _subject: Subject<T>;
constructor(subject: Subject<T>) {
this._subject = subject;
}
get value(): T {
return this._subject.value;
}
public subscribe(listener: (value: T) => void): Disposable {
return this._subject.subscribe(listener);
}
}

View File

@@ -0,0 +1,39 @@
import Disposable from '../disposables/Disposable';
export default class Subject<T> {
private readonly _listeners: Array<(value: T) => void> = [];
private _value: T;
constructor(value: T) {
this._value = value;
}
set value(value: T) {
if (this._value === value) {
return;
}
this._value = value;
this.notifyListeners(value);
}
get value(): T {
return this._value;
}
public subscribe(listener: (value: T) => void): Disposable {
const listenerIndex = this._listeners.push(listener);
listener(this.value);
return new Disposable(() => {
this._listeners.splice(listenerIndex, 1);
});
}
private notifyListeners(value: T): void {
for (const listener of this._listeners) {
listener(value);
}
}
}

4
src/observables/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import Subject from './Subject';
import ReadOnlySubject from './ReadOnlySubject';
export {Subject, ReadOnlySubject};

15
src/strings/index.ts Normal file
View File

@@ -0,0 +1,15 @@
export class Strings {
static random(length = 8) {
if (length > 11) {
throw new Error(`[Strings] max length [11]`);
}
return Math.random()
.toString(36)
.substring(2, length + 2);
}
constructor() {
throw new Error('Strings is a static class that may not be instantiated');
}
}