UNPKG

alepha

Version:

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

572 lines (571 loc) 15.4 kB
import { $hook, $inject, $module, Alepha, AlephaError, KIND, Primitive, createMiddleware, createPrimitive } from "alepha"; import dayjsRelativeTime from "dayjs/plugin/relativeTime.js"; import dayjsDuration from "dayjs/plugin/duration.js"; import dayjsUtc from "dayjs/plugin/utc.js"; import dayjsTimezone from "dayjs/plugin/timezone.js"; import dayjsLocalizedFormat from "dayjs/plugin/localizedFormat.js"; import "dayjs/locale/ar.js"; import "dayjs/locale/fr.js"; import DayjsApi from "dayjs"; //#region ../../src/datetime/providers/DateTimeProvider.ts /** * 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. */ var DateTime = class DateTime { inner; constructor(inner) { this.inner = inner; } add(amount, unit) { if (amount instanceof Duration) return new DateTime(this.inner.add(amount.toDayjs())); return new DateTime(this.inner.add(amount, unit)); } subtract(amount, unit) { if (amount instanceof Duration) return new DateTime(this.inner.subtract(amount.toDayjs())); return new DateTime(this.inner.subtract(amount, unit)); } startOf(unit) { return new DateTime(this.inner.startOf(unit)); } endOf(unit) { return new DateTime(this.inner.endOf(unit)); } isAfter(other) { return this.inner.isAfter(toDayjs(other)); } isBefore(other) { return this.inner.isBefore(toDayjs(other)); } isSame(other, unit) { return this.inner.isSame(toDayjs(other), unit); } diff(other, unit) { return this.inner.diff(toDayjs(other), unit); } tz(timezone) { return new DateTime(this.inner.tz(timezone)); } locale(lang) { return new DateTime(this.inner.locale(lang)); } format(template) { return this.inner.format(template); } fromNow(withoutSuffix) { return this.inner.fromNow(withoutSuffix); } toISOString() { return this.inner.toISOString(); } toDate() { return this.inner.toDate(); } valueOf() { return this.inner.valueOf(); } unix() { return this.inner.unix(); } toJSON() { return this.inner.toISOString(); } toString() { 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() { return this.inner; } }; /** * Immutable wrapper around the underlying duration engine. */ var Duration = class { inner; constructor(inner) { this.inner = inner; } asMilliseconds() { return this.inner.asMilliseconds(); } asSeconds() { return this.inner.asSeconds(); } asMinutes() { return this.inner.asMinutes(); } asHours() { return this.inner.asHours(); } asDays() { return this.inner.asDays(); } as(unit) { return this.inner.as(unit); } toISOString() { return this.inner.toISOString(); } /** * Escape hatch for the underlying dayjs duration. */ toDayjs() { return this.inner; } }; const isDateTime = (value) => { return value instanceof DateTime; }; const toDayjs = (value) => { if (value instanceof DateTime) return value.toDayjs(); return DayjsApi(value); }; var DateTimeProvider = class DateTimeProvider { static PLUGINS = [ dayjsDuration, dayjsRelativeTime, dayjsUtc, dayjsTimezone, dayjsLocalizedFormat ]; alepha = $inject(Alepha); ref = null; timeouts = []; intervals = []; constructor() { for (const plugin of DateTimeProvider.PLUGINS) DayjsApi.extend(plugin); } onStart = $hook({ on: "start", handler: async () => { await Promise.all(this.intervals.map(async (interval) => { if (interval.timer != null) return; await interval.run(); interval.timer = setInterval(interval.run, interval.duration); })); } }); 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; } } }); setLocale(locale) { DayjsApi.locale(locale); } isDateTime(value) { return value instanceof DateTime; } /** * Create a new UTC DateTime instance. */ utc(date) { return new DateTime(DayjsApi.utc(unwrap(date))); } /** * Create a new DateTime instance. */ of(date) { if (date instanceof DateTime) return date; return new DateTime(DayjsApi(date)); } /** * Get the current date as a string. */ toISOString(date = this.now()) { return this.of(date).toISOString(); } /** * Get the current date. */ now() { 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. */ nowISOString() { if (this.ref) return this.ref.toISOString(); return (/* @__PURE__ */ 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. */ nowMillis() { if (this.ref) return this.ref.valueOf(); return Date.now(); } /** * Get the current date as a string. * * @protected */ getCurrentDate() { if (this.ref) return this.ref; return new DateTime(DayjsApi()); } /** * Create a new Duration instance. */ duration = (duration, unit) => { 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; }; isDurationLike(value) { try { return DayjsApi.isDuration(this.duration(value).toDayjs()); } catch { return false; } } /** * Return a promise that resolves after the next tick. * It uses `setTimeout` with 0 ms delay. */ async tick() { 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. */ wait(duration, options = {}) { return new Promise((resolve) => { let clearTimeout; let callback; 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); } }); } createInterval(run, duration, start = false) { const 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. */ createTimeout(callback, duration, now) { if (this.ref && now) { if (this.of(now).add(this.duration(duration)).valueOf() < this.now().valueOf()) callback(); return { now, duration: 0, callback: () => {}, clear: () => {} }; } const 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; } clearTimeout(timeout) { clearTimeout(timeout.timer); timeout.duration = 0; timeout.timer = null; const index = this.timeouts.indexOf(timeout); if (index !== -1) this.timeouts.splice(index, 1); } clearInterval(interval) { clearInterval(interval.timer); interval.duration = 0; interval.timer = null; } /** * Run a function with a deadline. */ async deadline(fn, duration) { const abort = new AbortController(); const timeout = this.createTimeout(() => abort.abort(), duration); try { return await fn(abort.signal); } finally { this.clearTimeout(timeout); } } /** * Add time to the current date. */ async travel(duration, unit) { 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(); interval.timer = null; } await this.tick(); } /** * Stop the time. */ pause() { this.ref = this.ref || this.now(); return this.ref; } /** * Reset the reference date. */ reset() { this.ref = null; } }; const unwrap = (value) => { if (value instanceof DateTime) return value.toDayjs(); return value; }; //#endregion //#region ../../src/datetime/primitives/$interval.ts /** * Run a function periodically. * It uses the `setInterval` internally. * It starts by default when the context starts and stops when the context stops. */ const $interval = (options) => createPrimitive(IntervalPrimitive, options); var IntervalPrimitive = class extends Primitive { dateTimeProvider = $inject(DateTimeProvider); called = 0; onInit() { this.dateTimeProvider.createInterval(async () => { await this.options.handler(); this.called += 1; }, this.options.duration); } }; $interval[KIND] = IntervalPrimitive; //#endregion //#region ../../src/datetime/primitives/$debounce.ts /** * Middleware that coalesces concurrent calls with the same key into a single handler execution. * * All callers within the delay window receive the same result. No storage — * once the handler finishes, the next call starts fresh. Process-local. * * **Use case**: thundering herd protection — cache expires, 100 requests * hit the same endpoint, debounce ensures one rebuild. * * ```typescript * class SearchController { * search = $action({ * use: [$debounce({ delay: [200, "ms"], key: (req) => req.query.q })], * handler: async ({ query }) => this.searchService.search(query.q), * }); * } * ``` */ const $debounce = (options) => { return createMiddleware({ name: "$debounce", options, handler: ({ alepha, next }) => { const dateTimeProvider = alepha.inject(DateTimeProvider); const pending = /* @__PURE__ */ new Map(); return async (...args) => { const key = options.key?.(...args) ?? JSON.stringify(args); const existing = pending.get(key); if (existing) return existing; let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); dateTimeProvider.createTimeout(async () => { try { const result = await next(...args); resolve(result); } catch (error) { reject(error); } finally { pending.delete(key); } }, options.delay); pending.set(key, promise); return promise; }; } }); }; //#endregion //#region ../../src/datetime/primitives/$throttle.ts /** * Middleware that rate-controls handler execution using a token bucket. * * Excess calls are **delayed** until capacity is available — never rejected. * Process-local (not distributed) — it cannot rate-limit across instances. * * **Limitation**: the token refill is time-based and re-checked only when a * waiter wakes, so a burst of *concurrent* calls can wake in the same window * and briefly exceed `rate`. Treat it as traffic smoothing, not a hard cap — * do not rely on it to enforce a strict quota. * * **Use case**: protect an external API from your own traffic. * * ```typescript * class PaymentController { * charge = $action({ * use: [$throttle({ rate: 80, per: [1, "second"] })], * handler: async ({ body }) => this.stripe.charges.create(body), * }); * } * ``` */ const $throttle = (options) => { return createMiddleware({ name: "$throttle", options, handler: ({ alepha, next }) => { const dateTimeProvider = alepha.inject(DateTimeProvider); const intervalMs = dateTimeProvider.duration(options.per).asMilliseconds(); let tokens = options.rate; let lastRefill = dateTimeProvider.nowMillis(); return async (...args) => { const now = dateTimeProvider.nowMillis(); const elapsed = now - lastRefill; const refill = Math.floor(elapsed / intervalMs) * options.rate; if (refill > 0) { tokens = Math.min(options.rate, tokens + refill); lastRefill += Math.floor(elapsed / intervalMs) * intervalMs; } if (tokens <= 0) { const waitMs = intervalMs - (now - lastRefill); await dateTimeProvider.wait(waitMs); tokens = options.rate; lastRefill = dateTimeProvider.nowMillis(); } tokens--; return next(...args); }; } }); }; //#endregion //#region ../../src/datetime/primitives/$timeout.ts /** * Middleware that aborts handler execution if it exceeds a duration limit. * * Uses `Promise.race` with a managed timeout from `DateTimeProvider` — * if the handler doesn't resolve before the deadline, the promise rejects. * Uses managed timeouts so it works with `DateTimeProvider.travel()` in tests. * * ```typescript * class OrderService { * processOrder = $pipeline({ * use: [$timeout([30, "seconds"])], * handler: async (orderId: string) => { * return await this.orders.updateById(orderId, { status: "paid" }); * }, * }); * } * ``` */ const $timeout = (duration) => { return createMiddleware({ name: "$timeout", options: { duration }, handler: ({ alepha, next }) => { const dateTimeProvider = alepha.inject(DateTimeProvider); return async (...args) => { let rejectTimeout; const timeoutPromise = new Promise((_, reject) => { rejectTimeout = reject; }); const timer = dateTimeProvider.createTimeout(() => { rejectTimeout(new AlephaError("$timeout: handler exceeded deadline")); }, duration); try { return await Promise.race([next(...args), timeoutPromise]); } finally { dateTimeProvider.clearTimeout(timer); } }; } }); }; //#endregion //#region ../../src/datetime/index.ts /** * Date and time operations. * * **Features:** * - Recurring interval definitions * - Duration parsing (ISO 8601, human-readable) * - Timezone support * - Dayjs integration * * @module alepha.datetime */ const AlephaDateTime = $module({ name: "alepha.datetime", primitives: [$interval], services: [DateTimeProvider] }); //#endregion export { $debounce, $interval, $throttle, $timeout, AlephaDateTime, DateTime, DateTimeProvider, Duration, IntervalPrimitive, isDateTime }; //# sourceMappingURL=index.js.map