init
This commit is contained in:
4
packages/utils/.gitignore
vendored
Normal file
4
packages/utils/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/coverage/
|
||||
/.env
|
||||
29
packages/utils/package.json
Normal file
29
packages/utils/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
56
packages/utils/src/coalescing-queue.ts
Normal file
56
packages/utils/src/coalescing-queue.ts
Normal 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 };
|
||||
4
packages/utils/src/consts.ts
Normal file
4
packages/utils/src/consts.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
const API_GROUP = 'playground.homelab.olsen.cloud';
|
||||
const API_VERSION = `${API_GROUP}/v1`;
|
||||
|
||||
export { API_VERSION, API_GROUP };
|
||||
65
packages/utils/src/event-emitter.ts
Normal file
65
packages/utils/src/event-emitter.ts
Normal 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
2
packages/utils/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare type ExplicitAny = any;
|
||||
33
packages/utils/src/objects.ts
Normal file
33
packages/utils/src/objects.ts
Normal 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 };
|
||||
41
packages/utils/src/queue.ts
Normal file
41
packages/utils/src/queue.ts
Normal 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 };
|
||||
|
||||
51
packages/utils/src/services.ts
Normal file
51
packages/utils/src/services.ts
Normal 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 };
|
||||
9
packages/utils/tsconfig.json
Normal file
9
packages/utils/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"extends": "@morten-olsen/box-configs/tsconfig.json"
|
||||
}
|
||||
12
packages/utils/vitest.config.ts
Normal file
12
packages/utils/vitest.config.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user