UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

598 lines (505 loc) 14 kB
import "dayjs/plugin/relativeTime.js"; import "dayjs/plugin/duration.js"; import "dayjs/plugin/utc.js"; import "dayjs/plugin/timezone.js"; import "dayjs/plugin/localizedFormat.js"; import "dayjs/locale/ar.js"; import "dayjs/locale/fr.js"; import { $hook, $inject, Alepha } from "alepha"; import DayjsApi, { type Dayjs, type ManipulateType, type OpUnitType, type PluginFunc, type QUnitType, } from "dayjs"; import dayjsDuration, { type DurationUnitType } from "dayjs/plugin/duration.js"; import dayjsLocalizedFormat from "dayjs/plugin/localizedFormat.js"; import dayjsRelativeTime from "dayjs/plugin/relativeTime.js"; import dayjsTimezone from "dayjs/plugin/timezone.js"; import dayjsUtc from "dayjs/plugin/utc.js"; export type { DurationUnitType, ManipulateType, OpUnitType, QUnitType }; export type DateTimeInput = string | number | Date | DateTime | Dayjs; export type DurationLike = number | Duration | [number, ManipulateType]; /** * Immutable wrapper around the underlying date-time engine. * * Designed to isolate consumers from the engine in use (currently dayjs). * Methods that produce a new value return a new `DateTime` instance. */ export class DateTime { protected readonly inner: Dayjs; constructor(inner: Dayjs) { this.inner = inner; } /** * Add a duration to this date-time. */ add(amount: number, unit?: ManipulateType): DateTime; add(duration: Duration): DateTime; add(amount: number | Duration, unit?: ManipulateType): DateTime { if (amount instanceof Duration) { return new DateTime(this.inner.add(amount.toDayjs())); } return new DateTime(this.inner.add(amount, unit)); } /** * Subtract a duration from this date-time. */ subtract(amount: number, unit?: ManipulateType): DateTime; subtract(duration: Duration): DateTime; subtract(amount: number | Duration, unit?: ManipulateType): DateTime { if (amount instanceof Duration) { return new DateTime(this.inner.subtract(amount.toDayjs())); } return new DateTime(this.inner.subtract(amount, unit)); } startOf(unit: OpUnitType): DateTime { return new DateTime(this.inner.startOf(unit)); } endOf(unit: OpUnitType): DateTime { return new DateTime(this.inner.endOf(unit)); } isAfter(other: DateTimeInput): boolean { return this.inner.isAfter(toDayjs(other)); } isBefore(other: DateTimeInput): boolean { return this.inner.isBefore(toDayjs(other)); } isSame(other: DateTimeInput, unit?: OpUnitType): boolean { return this.inner.isSame(toDayjs(other), unit); } diff(other: DateTimeInput, unit?: QUnitType | OpUnitType): number { return this.inner.diff(toDayjs(other), unit); } tz(timezone: string): DateTime { return new DateTime(this.inner.tz(timezone)); } locale(lang: string): DateTime { return new DateTime(this.inner.locale(lang)); } format(template?: string): string { return this.inner.format(template); } fromNow(withoutSuffix?: boolean): string { return this.inner.fromNow(withoutSuffix); } toISOString(): string { return this.inner.toISOString(); } toDate(): Date { return this.inner.toDate(); } valueOf(): number { return this.inner.valueOf(); } unix(): number { return this.inner.unix(); } toJSON(): string { return this.inner.toISOString(); } toString(): string { return this.inner.toISOString(); } /** * Escape hatch for the underlying dayjs instance. * * Use sparingly — anything calling this becomes coupled to dayjs and * will need to migrate when the engine is replaced. */ toDayjs(): Dayjs { return this.inner; } } /** * Immutable wrapper around the underlying duration engine. */ export class Duration { protected readonly inner: dayjsDuration.Duration; constructor(inner: dayjsDuration.Duration) { this.inner = inner; } asMilliseconds(): number { return this.inner.asMilliseconds(); } asSeconds(): number { return this.inner.asSeconds(); } asMinutes(): number { return this.inner.asMinutes(); } asHours(): number { return this.inner.asHours(); } asDays(): number { return this.inner.asDays(); } as(unit: DurationUnitType): number { return this.inner.as(unit); } toISOString(): string { return this.inner.toISOString(); } /** * Escape hatch for the underlying dayjs duration. */ toDayjs(): dayjsDuration.Duration { return this.inner; } } export const isDateTime = (value: unknown): value is DateTime => { return value instanceof DateTime; }; const toDayjs = (value: DateTimeInput): Dayjs => { if (value instanceof DateTime) { return value.toDayjs(); } return DayjsApi(value as any); }; export class DateTimeProvider { public static PLUGINS: Array<PluginFunc<any>> = [ dayjsDuration, dayjsRelativeTime, dayjsUtc, dayjsTimezone, dayjsLocalizedFormat, ]; protected alepha = $inject(Alepha); protected ref: DateTime | null = null; protected readonly timeouts: Timeout[] = []; protected readonly intervals: Interval[] = []; constructor() { for (const plugin of DateTimeProvider.PLUGINS) { DayjsApi.extend(plugin); } } protected readonly onStart = $hook({ on: "start", handler: async () => { // we start intervals now but first tick will be rejected as App is not ready yet await Promise.all( this.intervals.map(async (interval) => { if (interval.timer != null) { return; } await interval.run(); interval.timer = setInterval(interval.run, interval.duration); }), ); }, }); protected readonly onStop = $hook({ on: "stop", handler: () => { for (const timeout of [...this.timeouts]) { this.clearTimeout(timeout); } for (const interval of this.intervals) { clearInterval(interval.timer); interval.duration = 0; interval.timer = null; } }, }); public setLocale(locale: string): void { DayjsApi.locale(locale); } public isDateTime(value: unknown): value is DateTime { return value instanceof DateTime; } /** * Create a new UTC DateTime instance. */ public utc(date: DateTimeInput | null | undefined): DateTime { return new DateTime(DayjsApi.utc(unwrap(date))); } /** * Create a new DateTime instance. */ public of(date: DateTimeInput | null | undefined): DateTime { if (date instanceof DateTime) { return date; } return new DateTime(DayjsApi(date as any)); } /** * Get the current date as a string. */ public toISOString(date: DateTimeInput = this.now()): string { return this.of(date).toISOString(); } /** * Get the current date. */ public now(): DateTime { return this.getCurrentDate(); } /** * Get the current date as a string. * * This is much faster than `DateTimeProvider.now().toISOString()` as it avoids creating a DateTime instance. */ public nowISOString(): string { if (this.ref) { return this.ref.toISOString(); } return new Date().toISOString(); } /** * Get the current date as milliseconds since epoch. * * This is much faster than `DateTimeProvider.now().valueOf()` as it avoids creating a DateTime instance. */ public nowMillis(): number { if (this.ref) { return this.ref.valueOf(); } return Date.now(); } /** * Get the current date as a string. * * @protected */ protected getCurrentDate(): DateTime { if (this.ref) { return this.ref; } return new DateTime(DayjsApi()); } /** * Create a new Duration instance. */ public duration = ( duration: DurationLike, unit?: ManipulateType, ): Duration => { if (duration instanceof Duration) { return duration; } if (Array.isArray(duration)) { return new Duration(DayjsApi.duration(duration[0], duration[1])); } if (typeof duration === "number") { return new Duration(DayjsApi.duration(duration, unit || "milliseconds")); } return duration; }; public isDurationLike(value: unknown): value is DurationLike { try { return DayjsApi.isDuration( this.duration(value as DurationLike).toDayjs(), ); } catch { return false; } } // ------------------------------------------------------------------------------------------------------------------- // Timer Management /** * Return a promise that resolves after the next tick. * It uses `setTimeout` with 0 ms delay. */ public async tick(): Promise<void> { await new Promise((resolve) => setTimeout(resolve, 0)); } /** * Wait for a certain duration. * * You can clear the timeout by using the `AbortSignal` API. * Aborted signal will resolve the promise immediately, it does not reject it. */ public wait( duration: DurationLike, options: { signal?: AbortSignal; now?: number; } = {}, ): Promise<void> { return new Promise((resolve) => { let clearTimeout: any; let callback: any; const timeout = this.createTimeout( () => { if (options.signal && clearTimeout) { options.signal.removeEventListener("abort", callback); } resolve(); }, duration, options.now, ); if (options.signal) { clearTimeout = () => this.clearTimeout(timeout); callback = () => { clearTimeout(); resolve(); }; options.signal.addEventListener("abort", callback); } }); } public createInterval( run: () => unknown, duration: DurationLike, start = false, ): Interval { const interval: Interval = { run, duration: this.duration(duration).asMilliseconds(), }; this.intervals.push(interval); if (start) { interval.timer = setInterval(interval.run, interval.duration); } return interval; } /** * Run a callback after a certain duration. */ public createTimeout( callback: () => void, duration: DurationLike, now?: number, ): Timeout { if (this.ref && now) { const next = this.of(now).add(this.duration(duration)); if (next.valueOf() < this.now().valueOf()) { callback(); } return { now, duration: 0, callback: () => {}, clear: () => {}, }; } const timeout: Timeout = { now: now ?? this.now().valueOf(), duration: this.duration(duration).asMilliseconds(), callback, clear: () => this.clearTimeout(timeout), }; timeout.timer = setTimeout(() => { const index = this.timeouts.indexOf(timeout); if (index !== -1) { this.timeouts.splice(index, 1); } timeout.callback(); }, timeout.duration); this.timeouts.push(timeout); return timeout; } public clearTimeout(timeout: Timeout): void { clearTimeout(timeout.timer); timeout.duration = 0; timeout.timer = null; const index = this.timeouts.indexOf(timeout); if (index !== -1) { this.timeouts.splice(index, 1); } } public clearInterval(interval: Interval): void { clearInterval(interval.timer); interval.duration = 0; interval.timer = null; } /** * Run a function with a deadline. */ public async deadline<T>( fn: (signal: AbortSignal) => Promise<T>, duration: DurationLike, ): Promise<T> { const abort = new AbortController(); const timeout = this.createTimeout(() => abort.abort(), duration); try { return await fn(abort.signal); } finally { this.clearTimeout(timeout); } } // ------------------------------------------------------------------------------------------------------------------- // Testing /** * Add time to the current date. */ public async travel( duration: DurationLike, unit?: ManipulateType, ): Promise<void> { this.ref = this.ref || this.now(); const ms = this.duration(duration, unit).asMilliseconds(); const now = this.nowMillis(); this.ref = this.ref.add(this.duration(duration, unit)); for (const timeout of [...this.timeouts]) { if (!timeout.timer) { continue; } clearTimeout(timeout.timer); timeout.timer = null; const spent = now - timeout.now; timeout.duration = timeout.duration - spent - ms; if (timeout.duration <= 0) { const index = this.timeouts.indexOf(timeout); if (index !== -1) { this.timeouts.splice(index, 1); } timeout.callback(); } else { timeout.timer = setTimeout(() => { const index = this.timeouts.indexOf(timeout); if (index !== -1) { this.timeouts.splice(index, 1); } timeout.callback(); }, timeout.duration); } } for (const interval of this.intervals) { if (!interval.timer) { continue; } clearInterval(interval.timer); const repeat = Math.floor(ms / interval.duration); for (let i = 0; i < repeat; i++) { await interval.run(); } // Keep intervals suspended — they only fire during travel() calls interval.timer = null; } await this.tick(); } /** * Stop the time. */ public pause(): DateTime { this.ref = this.ref || this.now(); return this.ref; } /** * Reset the reference date. */ public reset(): void { this.ref = null; } } const unwrap = (value: DateTimeInput | null | undefined): any => { if (value instanceof DateTime) { return value.toDayjs(); } return value; }; export interface Interval { timer?: any; duration: number; run: () => unknown; } export interface Timeout { now: number; timer?: any; duration: number; callback: () => void; clear: () => void; }