This commit is contained in:
Morten Olsen
2025-10-23 13:47:07 +02:00
commit b851dc3006
91 changed files with 7578 additions and 0 deletions

4
packages/utils/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/node_modules/
/dist/
/coverage/
/.env

View File

@@ -0,0 +1,29 @@
{
"type": "module",
"scripts": {
"build": "tsc --build",
"test:unit": "vitest --run --passWithNoTests",
"test": "pnpm run \"/^test:/\""
},
"packageManager": "pnpm@10.6.0",
"files": [
"dist"
],
"exports": {
"./*": "./dist/*.js"
},
"devDependencies": {
"@morten-olsen/box-configs": "workspace:*",
"@morten-olsen/box-tests": "workspace:*",
"@types/node": "catalog:",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"name": "@morten-olsen/box-utils",
"version": "1.0.0",
"dependencies": {
"p-queue": "^9.0.0",
"p-retry": "^7.1.0"
}
}

View File

@@ -0,0 +1,56 @@
type CoalescingQueueOptions<T> = {
action: () => Promise<T>;
};
const createResolvable = <T>() => {
// eslint-disable-next-line
let resolve: (item: T) => void = () => { };
// eslint-disable-next-line
let reject: (item: T) => void = () => { };
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { resolve, reject, promise };
};
type Resolveable<T> = ReturnType<typeof createResolvable<T>>;
class CoalescingQueue<T> {
#options: CoalescingQueueOptions<T>;
#next?: Resolveable<T>;
#current?: Promise<T>;
constructor(options: CoalescingQueueOptions<T>) {
this.#options = options;
}
#start = () => {
if (this.#current) {
return;
}
const next = this.#next;
if (next) {
const action = this.#options.action();
this.#current = action;
this.#next = undefined;
action.then(next.resolve);
action.catch(next.reject);
action.finally(() => {
this.#current = undefined;
this.#start();
});
}
};
public run = async () => {
if (!this.#next) {
this.#next = createResolvable<T>();
}
const next = this.#next;
this.#start();
return next.promise;
};
}
export { CoalescingQueue };

View File

@@ -0,0 +1,4 @@
const API_GROUP = 'playground.homelab.olsen.cloud';
const API_VERSION = `${API_GROUP}/v1`;
export { API_VERSION, API_GROUP };

View File

@@ -0,0 +1,65 @@
type EventListener<T extends unknown[]> = (...args: T) => void | Promise<void>;
type OnOptions = {
abortSignal?: AbortSignal;
};
class EventEmitter<T extends Record<string, (...args: ExplicitAny[]) => void | Promise<void>>> {
#listeners = new Map<keyof T, Set<EventListener<ExplicitAny>>>();
on = <K extends keyof T>(event: K, callback: EventListener<Parameters<T[K]>>, options: OnOptions = {}) => {
const { abortSignal } = options;
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
const callbackClone = (...args: Parameters<T[K]>) => callback(...args);
const abortController = new AbortController();
const listeners = this.#listeners.get(event);
if (!listeners) {
throw new Error('Event registration failed');
}
abortSignal?.addEventListener('abort', abortController.abort);
listeners.add(callbackClone);
abortController.signal.addEventListener('abort', () => {
this.#listeners.set(event, listeners?.difference(new Set([callbackClone])));
});
return abortController.abort;
};
once = <K extends keyof T>(event: K, callback: EventListener<Parameters<T[K]>>, options: OnOptions = {}) => {
const abortController = new AbortController();
options.abortSignal?.addEventListener('abort', abortController.abort);
return this.on(
event,
async (...args) => {
abortController.abort();
await callback(...args);
},
{
...options,
abortSignal: abortController.signal,
},
);
};
emit = <K extends keyof T>(event: K, ...args: Parameters<T[K]>) => {
const listeners = this.#listeners.get(event);
if (!listeners) {
return;
}
for (const listener of listeners) {
listener(...args);
}
};
emitAsync = async <K extends keyof T>(event: K, ...args: Parameters<T[K]>) => {
const listeners = this.#listeners.get(event);
if (!listeners) {
return;
}
await Promise.all(listeners.values().map((listener) => listener(...args)));
};
}
export { EventEmitter };

2
packages/utils/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare type ExplicitAny = any;

View File

@@ -0,0 +1,33 @@
function isDeepSubset<T>(actual: ExplicitAny, expected: T): expected is T {
if (typeof expected !== 'object' || expected === null) {
return actual === expected;
}
if (typeof actual !== 'object' || actual === null) {
return false;
}
if (Array.isArray(expected)) {
if (!Array.isArray(actual)) {
return false;
}
return expected.every((expectedItem) => actual.some((actualItem) => isDeepSubset(actualItem, expectedItem)));
}
// Iterate over the keys of the expected object
for (const key in expected) {
if (Object.prototype.hasOwnProperty.call(expected, key)) {
if (!Object.prototype.hasOwnProperty.call(actual, key)) {
return false;
}
if (!isDeepSubset(actual[key], expected[key])) {
return false;
}
}
}
return true;
};
export { isDeepSubset };

View File

@@ -0,0 +1,41 @@
import PQueue from 'p-queue';
import pRetry from 'p-retry';
type QueueCreateOptions = ConstructorParameters<typeof PQueue>[0];
type QueueAddOptions = Parameters<typeof pRetry>[1] & {
retries?: number;
};
type QueueOptions = QueueCreateOptions & {
retries?: number;
};
class Queue {
#options: QueueOptions;
#queue: PQueue;
constructor(options: QueueOptions = {}) {
this.#options = options;
this.#queue = new PQueue(options);
}
public get concurrency() {
return this.#queue.concurrency;
}
public set concurrency(value: number) {
this.#queue.concurrency = value;
}
public add = async <T>(task: () => Promise<T>, options: QueueAddOptions = {}) => {
const withRetry = () =>
pRetry(task, {
retries: options.retries || this.#options.retries || 1,
});
return this.#queue.add(withRetry);
};
}
export { Queue };

View File

@@ -0,0 +1,51 @@
const destroy = Symbol('destroy');
const instanceKey = Symbol('instances');
type ServiceDependency<T> = new (services: Services) => T & {
[destroy]?: () => Promise<void> | void;
};
class Services {
[instanceKey]: Map<ServiceDependency<unknown>, unknown>;
constructor() {
this[instanceKey] = new Map();
}
public get = <T>(service: ServiceDependency<T>) => {
if (!this[instanceKey].has(service)) {
this[instanceKey].set(service, new service(this));
}
const instance = this[instanceKey].get(service);
if (!instance) {
throw new Error('Could not generate instance');
}
return instance as T;
};
public set = <T>(service: ServiceDependency<T>, instance: Partial<T>) => {
this[instanceKey].set(service, instance);
};
public clone = () => {
const services = new Services();
services[instanceKey] = Object.fromEntries(this[instanceKey].entries());
};
public destroy = async () => {
await Promise.all(
this[instanceKey].values().map(async (instance) => {
if (
typeof instance === 'object' &&
instance &&
destroy in instance &&
typeof instance[destroy] === 'function'
) {
await instance[destroy]();
}
}),
);
};
}
export { Services, destroy };

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"outDir": "./dist"
},
"include": [
"src/**/*.ts"
],
"extends": "@morten-olsen/box-configs/tsconfig.json"
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import { getAliases } from '@morten-olsen/box-tests/vitest';
// eslint-disable-next-line import/no-default-export
export default defineConfig(async () => {
const aliases = await getAliases();
return {
resolve: {
alias: aliases,
},
};
});