move global services to "global" directory

This commit is contained in:
Hazelnoot 2025-10-07 23:22:15 -04:00
parent 00b216c83c
commit a55649e89a
192 changed files with 223 additions and 223 deletions

View file

@ -0,0 +1,157 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis';
import {
MemoryKVCache,
MemorySingleCache,
RedisKVCache,
RedisSingleCache,
type RedisKVCacheOpts,
type RedisSingleCacheOpts,
} from '@/misc/cache.js';
import { QuantumKVCache, type QuantumKVOpts } from '@/misc/QuantumKVCache.js';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import { TimeService, type TimerHandle } from '@/global/TimeService.js';
import { InternalEventService } from '@/global/InternalEventService.js';
// This is the one place that's *supposed* to new() up caches.
/* eslint-disable no-restricted-syntax */
export type ManagedMemoryKVCache<T> = Managed<MemoryKVCache<T>>;
export type ManagedMemorySingleCache<T> = Managed<MemorySingleCache<T>>;
export type ManagedRedisKVCache<T> = Managed<RedisKVCache<T>>;
export type ManagedRedisSingleCache<T> = Managed<RedisSingleCache<T>>;
export type ManagedQuantumKVCache<T> = Managed<QuantumKVCache<T>>;
export type Managed<T> = Omit<T, 'dispose' | 'onApplicationShutdown' | 'gc'>;
export type Manager = { dispose(): void, clear(): void, gc(): void };
export const GC_INTERVAL = 1000 * 60 * 3; // 3m
/**
* Creates and "manages" instances of any standard cache type.
* Instances produced by this class are automatically tracked for disposal when the application shuts down.
*/
@Injectable()
export class CacheManagementService implements OnApplicationShutdown {
private readonly managedCaches = new Set<Manager>();
private gcTimer?: TimerHandle | null;
constructor(
@Inject(DI.redis)
private readonly redisClient: Redis.Redis,
private readonly timeService: TimeService,
private readonly internalEventService: InternalEventService,
) {}
private get cacheServices() {
return {
internalEventService: this.internalEventService,
redisClient: this.redisClient,
timeService: this.timeService,
};
}
@bindThis
public createMemoryKVCache<T>(lifetime: number): ManagedMemoryKVCache<T> {
const cache = new MemoryKVCache<T>(lifetime, this.cacheServices);
return this.manageCache(cache);
}
@bindThis
public createMemorySingleCache<T>(lifetime: number): ManagedMemorySingleCache<T> {
const cache = new MemorySingleCache<T>(lifetime, this.cacheServices);
return this.manageCache(cache);
}
@bindThis
public createRedisKVCache<T>(name: string, opts: RedisKVCacheOpts<T>): ManagedRedisKVCache<T> {
const cache = new RedisKVCache<T>(name, this.cacheServices, opts);
return this.manageCache(cache);
}
@bindThis
public createRedisSingleCache<T>(name: string, opts: RedisSingleCacheOpts<T>): ManagedRedisSingleCache<T> {
const cache = new RedisSingleCache<T>(name, this.cacheServices, opts);
return this.manageCache(cache);
}
@bindThis
public createQuantumKVCache<T>(name: string, opts: QuantumKVOpts<T>): ManagedQuantumKVCache<T> {
const cache = new QuantumKVCache<T>(name, this.cacheServices, opts);
return this.manageCache(cache);
}
protected manageCache<T extends Manager>(cache: T): Managed<T> {
this.managedCaches.add(cache);
this.startGcTimer();
return cache;
}
@bindThis
public gc(): void {
this.resetGcTimer(() => {
// TODO callAll()
for (const manager of this.managedCaches) {
manager.gc();
}
});
}
@bindThis
public clear(): void {
this.resetGcTimer(() => {
for (const manager of this.managedCaches) {
manager.clear();
}
});
}
@bindThis
public async dispose(): Promise<void> {
this.stopGcTimer();
for (const manager of this.managedCaches) {
manager.dispose();
}
this.managedCaches.clear();
}
@bindThis
public async onApplicationShutdown(): Promise<void> {
await this.dispose();
}
@bindThis
private startGcTimer() {
// Only start it once, and don't *re* start since this gets called repeatedly.
this.gcTimer ??= this.timeService.startTimer(this.gc, GC_INTERVAL, { repeated: true });
}
@bindThis
private stopGcTimer() {
// Only stop it once, then clear the value so it can be restarted later.
if (this.gcTimer != null) {
this.timeService.stopTimer(this.gcTimer);
this.gcTimer = null;
}
}
@bindThis
private resetGcTimer(onBlank?: () => void): void {
this.stopGcTimer();
try {
if (onBlank) {
onBlank();
}
} finally {
this.startGcTimer();
}
}
}

View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import nodePath from 'node:path';
import nodeFs from 'node:fs';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { type ManagedMemoryKVCache, CacheManagementService } from '@/global/CacheManagementService.js';
/**
* Provides structured, mockable access to runtime/environment details.
*/
@Injectable()
export class EnvService {
protected readonly dependencyVersionCache: ManagedMemoryKVCache<string | null>;
constructor(cacheManagementService: CacheManagementService) {
this.dependencyVersionCache = cacheManagementService.createMemoryKVCache<string | null>(Infinity);
}
/**
* Returns the environment variables of the process.
* Can be modified, but modifications are not reflected to the operating system environment.
*/
public get env(): Partial<Record<string, string>> {
return process.env;
}
/**
* Returns the installed version of a given dependency, or null if not installed.
*/
@bindThis
public async getDependencyVersion(dependency: string): Promise<string | null> {
return await this.dependencyVersionCache.fetch(dependency, async () => {
const packageJsonPath = nodePath.join(import.meta.dirname, '../../package.json');
const packageJsonText = nodeFs.readFileSync(packageJsonPath, 'utf8');
// No "dependencies" section -> infer not installed.
const packageJson = JSON.parse(packageJsonText) as { dependencies?: Partial<Record<string, string>> };
if (packageJson.dependencies == null) return null;
// Not listed -> not installed.
const version = packageJson.dependencies['mfm-js'];
if (version == null) return null;
// Just in case some other value is there
return String(version);
});
}
}

View file

@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { GlobalEvents, InternalEventTypes } from '@/core/GlobalEventService.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
export type Listener<K extends keyof InternalEventTypes> = (value: InternalEventTypes[K], key: K, isLocal: boolean) => void | Promise<void>;
export interface ListenerProps {
ignoreLocal?: boolean,
ignoreRemote?: boolean,
}
@Injectable()
export class InternalEventService implements OnApplicationShutdown {
private readonly listeners = new Map<keyof InternalEventTypes, Map<Listener<keyof InternalEventTypes>, ListenerProps>>();
constructor(
@Inject(DI.redisForSub)
private readonly redisForSub: Redis.Redis,
@Inject(DI.redis)
private readonly redisForPub: Redis.Redis,
@Inject(DI.config)
private readonly config: Pick<Config, 'host'>,
) {
this.redisForSub.on('message', this.onMessage);
}
@bindThis
public on<K extends keyof InternalEventTypes>(type: K, listener: Listener<K>, props?: ListenerProps): void {
let set = this.listeners.get(type);
if (!set) {
set = new Map();
this.listeners.set(type, set);
}
// Functionally, this is just a set with metadata on the values.
set.set(listener as Listener<keyof InternalEventTypes>, props ?? {});
}
@bindThis
public off<K extends keyof InternalEventTypes>(type: K, listener: Listener<K>): void {
this.listeners.get(type)?.delete(listener as Listener<keyof InternalEventTypes>);
}
@bindThis
public async emit<K extends keyof InternalEventTypes>(type: K, value: InternalEventTypes[K]): Promise<void> {
await this.emitInternal(type, value, true);
await this.redisForPub.publish(this.config.host, JSON.stringify({
channel: 'internal',
message: { type: type, body: value },
}));
}
@bindThis
private async emitInternal<K extends keyof InternalEventTypes>(type: K, value: InternalEventTypes[K], isLocal: boolean): Promise<void> {
const listeners = this.listeners.get(type);
if (!listeners) {
return;
}
const promises: Promise<void>[] = [];
for (const [listener, props] of listeners) {
if ((isLocal && !props.ignoreLocal) || (!isLocal && !props.ignoreRemote)) {
const promise = Promise.resolve(listener(value, type, isLocal));
promises.push(promise);
}
}
await Promise.all(promises);
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
if (!isLocalInternalEvent(body) || body._pid !== process.pid) {
await this.emitInternal(type, body as InternalEventTypes[keyof InternalEventTypes], false);
}
}
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
this.listeners.clear();
}
@bindThis
public onApplicationShutdown(): void {
this.dispose();
}
}
interface LocalInternalEvent {
_pid: number;
}
function isLocalInternalEvent(body: object): body is LocalInternalEvent {
return '_pid' in body && typeof(body._pid) === 'number';
}

View file

@ -0,0 +1,182 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
const timerTokenSymbol = Symbol('timerToken');
/**
* Provides abstractions to access the current time.
* Exists for unit testing purposes, so that tests can "simulate" any given time for consistency.
*/
@Injectable()
export abstract class TimeService<TTimer extends Timer = Timer> implements OnApplicationShutdown {
protected readonly timers = new Map<symbol, TTimer>();
protected constructor() {}
/**
* Returns the current time, in milliseconds since the Unix epoch.
*/
public abstract get now(): number;
/**
* Returns a new Date instance representing the current time.
*/
public get date(): Date {
return new Date(this.now);
}
public startTimer(callback: () => void, delay: number, opts?: TimerOpts): TimerHandle;
public startTimer<T>(callback: (value: T) => void, delay: number, opts: TimerOpts | undefined, value: T): TimerHandle;
@bindThis
public startTimer<T = undefined>(callback: (value: T) => void, delay: number, opts?: TimerOpts, value?: T): TimerHandle {
const timerId = Symbol();
const repeating = opts?.repeated ?? false;
const timer = this.startNativeTimer(timerId, repeating, () => {
callback(value as T); // overloads ensure it can't be null
}, delay);
this.timers.set(timerId, timer);
return timerId;
}
public startPromiseTimer(delay: number): PromiseTimerHandle;
public startPromiseTimer<T>(delay: number, value: T, opts?: PromiseTimerOpts): PromiseTimerHandle<T>;
@bindThis
public startPromiseTimer<T = undefined>(delay: number, value?: T, opts?: PromiseTimerOpts): PromiseTimerHandle<T> {
const timerId = Symbol();
const abortController = new AbortController();
const abortSignal = opts?.signal ? AbortSignal.any([abortController.signal, opts.signal]) : abortController.signal;
const handlePromise = new Promise<T>((resolve, reject) => {
// Connect AbortSignal
abortSignal.throwIfAborted();
abortSignal.addEventListener('abort', () => reject(abortSignal.reason));
// Start the underlying timer
this.startTimer<T>(resolve, delay, undefined, value as T); // overloads ensure it can't be null
});
// Make sure we dispose the real handle if promise rejects!
handlePromise.catch(() => {
this.stopTimer(timerId);
});
// Populate and return the handle.
return Object.assign(handlePromise, {
[timerTokenSymbol]: timerId,
abort: (reason: Error) => {
abortController.abort(reason);
},
});
}
protected abstract startNativeTimer(timerId: symbol, repeating: boolean, callback: () => void, delay: number): TTimer;
/**
* Clears a registered timeout or interval.
* Returns true if the registration exists and was still active, false otherwise.
* Safe to call with invalid or expired IDs.
*/
@bindThis
public stopTimer(handle: TimerHandle | PromiseTimerHandle): boolean {
const id = typeof(handle) === 'object' ? handle[timerTokenSymbol] : handle;
const reg = this.timers.get(id);
if (!reg) return false;
this.stopNativeTimer(reg);
this.timers.delete(id);
return true;
}
protected abstract stopNativeTimer(reg: TTimer): void;
/**
* Cleanup all handles and references.
* Safe to call multiple times.
*
* **Must be called before shutting down the app!**
*/
@bindThis
public dispose(): void {
for (const reg of this.timers.values()) {
this.stopNativeTimer(reg);
}
this.timers.clear();
}
@bindThis
onApplicationShutdown(): void {
this.dispose();
}
}
export interface Timer {
timerId: symbol;
repeating: boolean;
delay: number;
callback: () => void;
}
export interface TimerOpts {
repeated?: boolean;
}
export type TimerHandle = symbol;
export interface PromiseTimerOpts {
signal?: AbortSignal;
}
export interface PromiseTimerHandle<T = void> extends PromiseLike<T> {
readonly [timerTokenSymbol]: symbol;
abort(error?: Error): void;
}
/**
* Default implementation of TimeService, uses Date.now() as time source and setTimeout/setInterval for timers.
*/
@Injectable()
export class NativeTimeService extends TimeService<NativeTimer> implements OnApplicationShutdown {
public get now(): number {
// This is the one place that actually *should* have it
// eslint-disable-next-line no-restricted-properties
return Date.now();
}
public constructor() {
super();
}
protected startNativeTimer(timerId: symbol, repeating: boolean, callback: () => void, delay: number): NativeTimer {
// Wrap the caller's callback to make sure we clean up the registration.
const wrappedCallback = () => {
this.timers.delete(timerId);
callback();
};
const timeout = repeating
? global.setInterval(wrappedCallback, delay)
: global.setTimeout(wrappedCallback, delay);
return { callback, timerId, repeating, delay, timeout };
}
protected stopNativeTimer(reg: NativeTimer): void {
if (reg.repeating) {
global.clearInterval(reg.timeout);
} else {
global.clearTimeout(reg.timeout);
}
}
}
export interface NativeTimer extends Timer {
timeout: NodeJS.Timeout;
}