178 lines
5.8 KiB
TypeScript
178 lines
5.8 KiB
TypeScript
import { BehaviorSubject, Observable, Subscription, of } from 'rxjs';
|
|
import type { StatePart } from './smartstate.classes.statepart.js';
|
|
import type { StateAction } from './smartstate.classes.stateaction.js';
|
|
|
|
export type TProcessStatus = 'idle' | 'running' | 'paused' | 'disposed';
|
|
export type TAutoPause = 'visibility' | Observable<boolean> | false;
|
|
|
|
export interface IProcessOptions<TStatePayload, TProducerValue> {
|
|
producer: () => Observable<TProducerValue>;
|
|
reducer: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
|
autoPause?: TAutoPause;
|
|
autoStart?: boolean;
|
|
}
|
|
|
|
export interface IScheduledActionOptions<TStatePayload, TActionPayload> {
|
|
action: StateAction<TStatePayload, TActionPayload>;
|
|
payload: TActionPayload;
|
|
intervalMs: number;
|
|
autoPause?: TAutoPause;
|
|
}
|
|
|
|
/**
|
|
* creates an Observable<boolean> from the Page Visibility API.
|
|
* emits true when the page is visible, false when hidden.
|
|
* in Node.js (no document), returns an always-true observable.
|
|
*/
|
|
function createVisibilityObservable(): Observable<boolean> {
|
|
if (typeof document === 'undefined') {
|
|
return of(true);
|
|
}
|
|
return new Observable<boolean>((subscriber) => {
|
|
subscriber.next(!document.hidden);
|
|
const handler = () => subscriber.next(!document.hidden);
|
|
document.addEventListener('visibilitychange', handler);
|
|
return () => document.removeEventListener('visibilitychange', handler);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* a managed, pausable process that ties an observable producer to state updates.
|
|
* supports lifecycle management (start/pause/resume/dispose) and auto-pause signals.
|
|
*/
|
|
export class StateProcess<TStatePartName, TStatePayload, TProducerValue> {
|
|
private readonly statePartRef: StatePart<TStatePartName, TStatePayload>;
|
|
private readonly producerFn: () => Observable<TProducerValue>;
|
|
private readonly reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
|
private readonly sideEffect?: (value: TProducerValue) => Promise<void> | void;
|
|
private readonly autoPauseOption: TAutoPause;
|
|
|
|
private statusSubject = new BehaviorSubject<TProcessStatus>('idle');
|
|
private producerSubscription: Subscription | null = null;
|
|
private autoPauseSubscription: Subscription | null = null;
|
|
private processingQueue: Promise<void> = Promise.resolve();
|
|
|
|
constructor(
|
|
statePartRef: StatePart<TStatePartName, TStatePayload>,
|
|
options: {
|
|
producer: () => Observable<TProducerValue>;
|
|
reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
|
|
sideEffect?: (value: TProducerValue) => Promise<void> | void;
|
|
autoPause?: TAutoPause;
|
|
}
|
|
) {
|
|
this.statePartRef = statePartRef;
|
|
this.producerFn = options.producer;
|
|
this.reducer = options.reducer;
|
|
this.sideEffect = options.sideEffect;
|
|
this.autoPauseOption = options.autoPause ?? false;
|
|
}
|
|
|
|
public get status(): TProcessStatus {
|
|
return this.statusSubject.getValue();
|
|
}
|
|
|
|
public get status$(): Observable<TProcessStatus> {
|
|
return this.statusSubject.asObservable();
|
|
}
|
|
|
|
public start(): void {
|
|
if (this.status === 'disposed') {
|
|
throw new Error('Cannot start a disposed process');
|
|
}
|
|
if (this.status === 'running') return;
|
|
this.statusSubject.next('running');
|
|
this.subscribeProducer();
|
|
this.setupAutoPause();
|
|
}
|
|
|
|
public pause(): void {
|
|
if (this.status === 'disposed') {
|
|
throw new Error('Cannot pause a disposed process');
|
|
}
|
|
if (this.status !== 'running') return;
|
|
this.statusSubject.next('paused');
|
|
this.unsubscribeProducer();
|
|
}
|
|
|
|
public resume(): void {
|
|
if (this.status === 'disposed') {
|
|
throw new Error('Cannot resume a disposed process');
|
|
}
|
|
if (this.status !== 'paused') return;
|
|
this.statusSubject.next('running');
|
|
this.subscribeProducer();
|
|
}
|
|
|
|
public dispose(): void {
|
|
if (this.status === 'disposed') return;
|
|
this.unsubscribeProducer();
|
|
this.teardownAutoPause();
|
|
this.statusSubject.next('disposed');
|
|
this.statusSubject.complete();
|
|
this.statePartRef._removeProcess(this);
|
|
}
|
|
|
|
private subscribeProducer(): void {
|
|
this.unsubscribeProducer();
|
|
const source = this.producerFn();
|
|
this.producerSubscription = source.subscribe({
|
|
next: (value) => {
|
|
// Queue value processing to ensure each reads fresh state after the previous completes
|
|
this.processingQueue = this.processingQueue.then(async () => {
|
|
try {
|
|
if (this.sideEffect) {
|
|
await this.sideEffect(value);
|
|
} else if (this.reducer) {
|
|
const currentState = this.statePartRef.getState();
|
|
if (currentState !== undefined) {
|
|
await this.statePartRef.setState(this.reducer(currentState, value));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('StateProcess value handling error:', err);
|
|
}
|
|
});
|
|
},
|
|
error: (err) => {
|
|
console.error('StateProcess producer error:', err);
|
|
if (this.status === 'running') {
|
|
this.statusSubject.next('paused');
|
|
this.unsubscribeProducer();
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
private unsubscribeProducer(): void {
|
|
if (this.producerSubscription) {
|
|
this.producerSubscription.unsubscribe();
|
|
this.producerSubscription = null;
|
|
}
|
|
}
|
|
|
|
private setupAutoPause(): void {
|
|
this.teardownAutoPause();
|
|
if (!this.autoPauseOption) return;
|
|
|
|
const signal$ = this.autoPauseOption === 'visibility'
|
|
? createVisibilityObservable()
|
|
: this.autoPauseOption;
|
|
|
|
this.autoPauseSubscription = signal$.subscribe((active) => {
|
|
if (!active && this.status === 'running') {
|
|
this.pause();
|
|
} else if (active && this.status === 'paused') {
|
|
this.resume();
|
|
}
|
|
});
|
|
}
|
|
|
|
private teardownAutoPause(): void {
|
|
if (this.autoPauseSubscription) {
|
|
this.autoPauseSubscription.unsubscribe();
|
|
this.autoPauseSubscription = null;
|
|
}
|
|
}
|
|
}
|