alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
572 lines (571 loc) • 15.4 kB
JavaScript
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