diff --git a/__tests__/plugins.test.ts b/__tests__/plugins.test.ts new file mode 100644 index 0000000..3a97301 --- /dev/null +++ b/__tests__/plugins.test.ts @@ -0,0 +1,237 @@ +import { performance } from 'perf_hooks'; + +import { jest } from '@jest/globals'; +import { number } from '@typeofweb/schema'; + +import { createApp, createPlugin } from '../src'; +import { ms } from '../src/utils/ms'; +import { wait } from '../src/utils/utils'; + +declare module '../src' { + interface TypeOfWebServerMeta { + readonly myPlugin: { + readonly someValue: number; + getUserById(id: string): Promise; + }; + } +} + +describe('plugins', () => { + it('should return function when cache is used', async () => { + const plugin = createPlugin('myPlugin', (_app) => { + return { + server(_server) { + return { + someValue: 42, + getUserById: { + cache: { + expireIn: ms('1 ms'), + }, + fn: (id) => Promise.resolve(id.split('').map(Number)), + }, + }; + }, + }; + }); + const app = createApp({}).route({ + path: '/cache', + method: 'get', + validation: {}, + handler: (request, _t) => { + return request.server.plugins.myPlugin.getUserById('123'); + }, + }); + await app.plugin(plugin); + + const result = await app.inject({ + method: 'get', + path: '/cache', + }); + expect(result.body).toEqual([1, 2, 3]); + }); + + it('should call the function only once when in cache', async () => { + const fn = jest.fn((id: string) => Promise.resolve(id.split('').map(Number))); + + const plugin = createPlugin('myPlugin', (_app) => { + return { + server(_server) { + return { + someValue: 42, + getUserById: { + cache: { + expireAt: '00:00', + }, + fn, + }, + }; + }, + }; + }); + + const app = createApp({}).route({ + path: '/cache', + method: 'get', + validation: {}, + handler: (request, _t) => { + return request.server.plugins.myPlugin.getUserById('123'); + }, + }); + await app.plugin(plugin); + + await app.inject({ + method: 'get', + path: '/cache', + }); + const result = await app.inject({ + method: 'get', + path: '/cache', + }); + expect(result.body).toEqual([1, 2, 3]); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should call the function multiple times when cache expires', async () => { + const fn = jest.fn((id: string) => Promise.resolve(id.split('').map(Number))); + + const plugin = createPlugin('myPlugin', (_app) => { + return { + server(_server) { + return { + someValue: 42, + getUserById: { + cache: { + expireIn: ms('10 ms'), + }, + fn, + }, + }; + }, + }; + }); + + const app = createApp({}).route({ + path: '/cache', + method: 'get', + validation: {}, + handler: (request, _t) => { + return request.server.plugins.myPlugin.getUserById('123'); + }, + }); + await app.plugin(plugin); + + await app.inject({ + method: 'get', + path: '/cache', + }); + await wait(100); + await app.inject({ + method: 'get', + path: '/cache', + }); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should differentiate functions by parameters', async () => { + const fn = jest.fn((id: string) => Promise.resolve(id.split('').map(Number))); + + const plugin = createPlugin('myPlugin', (_app) => { + return { + server(_server) { + return { + someValue: 42, + getUserById: { + cache: { + expireIn: ms('1 second'), + }, + fn, + }, + }; + }, + }; + }); + + const app = createApp({}).route({ + path: '/cache/:seed', + method: 'get', + validation: { + params: { + seed: number(), + }, + }, + handler: (request, _t) => { + return request.server.plugins.myPlugin.getUserById(request.params.seed.toString()); + }, + }); + await app.plugin(plugin); + + const result1 = await app.inject({ + method: 'get', + path: '/cache/123', + }); + expect(result1.body).toEqual([1, 2, 3]); + expect(fn).toHaveBeenCalledTimes(1); + + const result2 = await app.inject({ + method: 'get', + path: '/cache/444', + }); + expect(result2.body).toEqual([4, 4, 4]); + expect(fn).toHaveBeenCalledTimes(2); + + const result3 = await app.inject({ + method: 'get', + path: '/cache/123', + }); + expect(result3.body).toEqual([1, 2, 3]); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should call the function only once even when multiple requests are in parallel', async () => { + const FUNCTION_STALLING = ms('1 second'); + + const fn = jest.fn(async (id: string) => { + await wait(FUNCTION_STALLING); + return id.split('').map(Number); + }); + + const plugin = createPlugin('myPlugin', (_app) => { + return { + server(_server) { + return { + someValue: 42, + getUserById: { + cache: { + expireIn: ms('1 minute'), + }, + fn, + }, + }; + }, + }; + }); + + const app = createApp({}).route({ + path: '/cache', + method: 'get', + validation: {}, + handler: (request, _t) => { + return request.server.plugins.myPlugin.getUserById('123'); + }, + }); + await app.plugin(plugin); + + const before = performance.now(); + await Promise.all( + Array.from({ length: 100 }).map(() => + app.inject({ + method: 'get', + path: '/cache', + }), + ), + ); + const after = performance.now(); + expect(after - before).toBeLessThan(2 * FUNCTION_STALLING); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/serializeObject.test.ts b/__tests__/serializeObject.test.ts new file mode 100644 index 0000000..db08330 --- /dev/null +++ b/__tests__/serializeObject.test.ts @@ -0,0 +1,16 @@ +import { stableJsonStringify } from '../src/utils/serializeObject'; + +describe('stableJsonStringify', () => { + it('should stable sort regardless of properties order', () => { + expect(stableJsonStringify({ a: 123, b: 444 })).toEqual(stableJsonStringify({ b: 444, a: 123 })); + }); + + it('should stable stringify when mutating objects', () => { + const obj: Record = { a: 123, c: 444, d: 0 }; + obj.b = 333; + obj.e = 222; + delete obj.c; + + expect(stableJsonStringify(obj)).toEqual(stableJsonStringify({ a: 123, b: 333, e: 222, d: 0 })); + }); +}); diff --git a/examples/simple.ts b/examples/simple.ts index 0d24308..5c804a3 100644 --- a/examples/simple.ts +++ b/examples/simple.ts @@ -3,7 +3,7 @@ import { number } from '@typeofweb/schema'; import { createApp, createPlugin } from '../dist/index'; declare function findOne(): unknown; -declare function findMany(): unknown; +declare function findMany(): 123; declare module '../dist/index' { interface TypeOfWebServerMeta { readonly db: { @@ -21,7 +21,7 @@ declare module '../dist/index' { export const dbPlugin = createPlugin('db', () => { return { server() { - return { findOne, findMany }; + return { findOne, findMany: { cache: {}, fn: findMany } }; }, }; }); @@ -49,7 +49,7 @@ void app.route({ }, handler(_request) { // const { query, params } = request; - // request.server.plugins.db.findMany(); + _request.server.plugins.db.findMany(); // request.server.events.emit('health-check', 123); return 1; }, diff --git a/package.json b/package.json index 66900b0..5bcad56 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,12 @@ ], "dependencies": { "@typeofweb/schema": "0.8.0-5", + "@types/cache-manager": "3.4.0", "@types/cookie-parser": "1.4.2", "@types/cors": "2.8.10", "@types/express": "4.17.12", "@types/supertest": "2.0.11", + "cache-manager": "3.4.4", "cookie-parser": "1.4.5", "cors": "2.8.5", "express": "4.17.1", diff --git a/src/index.ts b/src/index.ts index c7db988..bd67f6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export { HttpError, isStatusError } from './utils/errors'; export { createApp } from './modules/app'; export { HttpStatusCode } from './modules/httpStatusCodes'; export { createPlugin } from './modules/plugins'; +export { createCachedFunction } from './modules/cache'; export type { HttpMethod } from './modules/httpStatusCodes'; export type { TypeOfWebApp, AppOptions, TypeOfWebServer } from './modules/shared'; diff --git a/src/modules/app.ts b/src/modules/app.ts index bda601c..93aea17 100644 --- a/src/modules/app.ts +++ b/src/modules/app.ts @@ -1,5 +1,6 @@ import { URL } from 'url'; +import CacheManager from 'cache-manager'; import CookieParser from 'cookie-parser'; import Cors from 'cors'; import Supertest from 'supertest'; @@ -8,14 +9,15 @@ import { deepMerge } from '../utils/merge'; import { promiseOriginFnToNodeCallback } from '../utils/node'; import { generateServerId } from '../utils/uniqueId'; +import { createCachedFunction } from './cache'; import { createEventBus } from './events'; import { initApp, listenExpressServer } from './http'; import { initRouter, validateRoute } from './router'; -import type { DeepPartial, DeepWritable } from '../utils/types'; +import type { AnyFunction, DeepPartial, DeepWritable, JsonPrimitive, MaybeAsync } from '../utils/types'; import type { TypeOfWebServerMeta } from './augment'; import type { TypeOfWebPluginInternal } from './plugins'; -import type { AppOptions, TypeOfWebApp, TypeOfWebServer } from './shared'; +import type { AppOptions, TypeOfWebRoute, TypeOfWebApp, TypeOfWebServer, TypeOfWebCacheConfig } from './shared'; const defaultAppOptions: AppOptions = { hostname: 'localhost', @@ -38,6 +40,7 @@ const defaultAppOptions: AppOptions = { export function createApp(opts: DeepPartial): TypeOfWebApp { const options = deepMerge(opts, defaultAppOptions); + const memoryCache = CacheManager.caching({ store: 'memory', ttl: 0 }); const server: DeepWritable = { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- these properties are supposed to be added by the plugins inside `async start()` @@ -51,7 +54,7 @@ export function createApp(opts: DeepPartial): TypeOfWebApp { }; /* eslint-disable functional/prefer-readonly-type -- ok */ - const routes: Array[0]> = []; + const routes: Array = []; /* eslint-disable functional/prefer-readonly-type -- ok */ const plugins: Array> = []; @@ -64,14 +67,33 @@ export function createApp(opts: DeepPartial): TypeOfWebApp { return; } - // @ts-expect-error - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- serverMetadata will have valid type - const serverMetadata = (await plugin.value.server( - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- make server readonly - server as TypeOfWebServer, - )) as unknown as TypeOfWebServerMeta[keyof TypeOfWebServerMeta]; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ok + const pluginServer = plugin.value.server as unknown as (server: TypeOfWebServer) => MaybeAsync< + Record< + string, + | JsonPrimitive + | AnyFunction + | { + readonly cache: TypeOfWebCacheConfig; + readonly fn: AnyFunction; + } + > + >; + + const result = await pluginServer(server); + const serverMetadata = result + ? Object.fromEntries( + Object.entries(result).map(([key, val]) => { + if (typeof val === 'object' && val && 'cache' in val) { + return [key, createCachedFunction({ ...val, cacheInstance: memoryCache })]; + } + return [key, val]; + }), + ) + : null; if (serverMetadata) { + // @ts-expect-error // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- if serverMetadata exists then plugin name is keyof TypeOfWebServerMeta server.plugins[plugin.name as keyof TypeOfWebServerMeta] = serverMetadata; } diff --git a/src/modules/cache.ts b/src/modules/cache.ts new file mode 100644 index 0000000..1585649 --- /dev/null +++ b/src/modules/cache.ts @@ -0,0 +1,53 @@ +import { invariant } from '../utils/errors'; +import { stableJsonStringify } from '../utils/serializeObject'; + +import type { Json } from '../utils/types'; +import type { TypeOfWebCacheConfig } from './shared'; +import type CacheManager from 'cache-manager'; + +const serializeArgs = (args: Json): string => stableJsonStringify(args); + +export const createCachedFunction = any>({ + fn, + cache, + cacheInstance, +}: { + readonly fn: Fn; + readonly cache: TypeOfWebCacheConfig; + readonly cacheInstance: CacheManager.Cache; +}): Fn => { + const ttlMs = 'expireIn' in cache ? cache.expireIn : expireAtToTtlMs(cache.expireAt); + + invariant(ttlMs, 'TTL is undefined - something went wrong'); + invariant(ttlMs > 0, 'TTL<=0 - something went wrong'); + + // cache-manager requires ttl in seconds + const ttl = ttlMs / 1000; + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ok + return function (...args) { + const id = serializeArgs(args); + return cacheInstance.wrap>( + id, + () => { + return fn(...args); + }, + { ttl }, + ); + } as Fn; +}; + +function expireAtToTtlMs(expireAt: Exclude) { + const [hours = '00', minutes = '00'] = expireAt.split(':'); + const expireAtDate = new Date(); + expireAtDate.setHours(Number.parseInt(hours)); + expireAtDate.setMinutes(Number.parseInt(minutes)); + const now = new Date(); + + if (expireAtDate <= now) { + // if expire at is in the past then surely it was meant to be the next day + expireAtDate.setDate(expireAtDate.getDate() + 1); + } + + return expireAtDate.getTime() - now.getTime(); +} diff --git a/src/modules/plugins.ts b/src/modules/plugins.ts index 7d86c38..ecd15f7 100644 --- a/src/modules/plugins.ts +++ b/src/modules/plugins.ts @@ -1,14 +1,25 @@ -import type { MaybeAsync } from '../utils/types'; +import type { AnyAsyncFunction, MaybeAsync } from '../utils/types'; import type { TypeOfWebServerMeta, TypeOfWebRequestMeta } from './augment'; -import type { TypeOfWebRequest, TypeOfWebApp, TypeOfWebServer } from './shared'; +import type { TypeOfWebApp, TypeOfWebServer, HandlerArguments, TypeOfWebCacheConfig } from './shared'; type PluginName_ = keyof TypeOfWebServerMeta | keyof TypeOfWebRequestMeta; +export type TypeOfWebServerMetaWithCachedFunctions = { + readonly [K in keyof TypeOfWebServerMeta[PluginName]]: TypeOfWebServerMeta[PluginName][K] extends AnyAsyncFunction + ? + | TypeOfWebServerMeta[PluginName][K] + | { readonly cache: TypeOfWebCacheConfig; readonly fn: TypeOfWebServerMeta[PluginName][K] } + : TypeOfWebServerMeta[PluginName][K]; +}; + type PluginCallbackReturnServer = PluginName extends keyof TypeOfWebServerMeta - ? { readonly server: (server: TypeOfWebServer) => MaybeAsync } + ? { readonly server: (server: TypeOfWebServer) => MaybeAsync> } : { readonly server?: never }; + type PluginCallbackReturnRequest = PluginName extends keyof TypeOfWebRequestMeta - ? { readonly request: (request: TypeOfWebRequest) => MaybeAsync } + ? { + readonly request: (...args: HandlerArguments) => MaybeAsync; + } : { readonly request?: never }; export type PluginCallbackReturnValue = PluginCallbackReturnServer & diff --git a/src/modules/router.ts b/src/modules/router.ts index 88cc21f..26056e5 100644 --- a/src/modules/router.ts +++ b/src/modules/router.ts @@ -2,7 +2,7 @@ import { object, validate, ValidationError } from '@typeofweb/schema'; import Express from 'express'; import { isSealed, seal, unseal } from '../utils/encryptCookies'; -import { HttpError, isStatusError, tryCatch } from '../utils/errors'; +import { HttpError, invariant, isStatusError, tryCatch } from '../utils/errors'; import { deepMerge } from '../utils/merge'; import { calculateSpecificity } from '../utils/routeSpecificity'; import { generateRequestId } from '../utils/uniqueId'; @@ -13,7 +13,14 @@ import type { Json, MaybeAsync } from '../utils/types'; import type { TypeOfWebRequestMeta } from './augment'; import type { HttpMethod } from './httpStatusCodes'; import type { TypeOfWebPluginInternal } from './plugins'; -import type { AppOptions, TypeOfWebRequest, TypeOfWebRequestToolkit, TypeOfWebRoute, TypeOfWebServer } from './shared'; +import type { + AppOptions, + HandlerArguments, + TypeOfWebRequest, + TypeOfWebRequestToolkit, + TypeOfWebRoute, + TypeOfWebServer, +} from './shared'; import type { SchemaRecord, TypeOfRecord } from './validation'; import type { SomeSchema, TypeOf } from '@typeofweb/schema'; @@ -63,14 +70,16 @@ export const validateRoute = (route: TypeOfWebRoute): boolean => { const segments = route.path.split('/'); const eachRouteSegmentHasAtMostOneParam = segments.every((segment) => (segment.match(/:/g) ?? []).length <= 1); - if (!eachRouteSegmentHasAtMostOneParam) { - throw new Error(`RouteValidationError: Each path segment can contain at most one param.`); - } + invariant( + eachRouteSegmentHasAtMostOneParam, + `RouteValidationError: Each path segment can contain at most one param.`, + ); const routeDoesntHaveRegexes = segments.every((segment) => !segment.endsWith(')')); - if (!routeDoesntHaveRegexes) { - throw new Error(`RouteValidationError: Don't use regular expressions in routes. Use validators instead.`); - } + invariant( + routeDoesntHaveRegexes, + `RouteValidationError: Don't use regular expressions in routes. Use validators instead.`, + ); return true; }; @@ -216,8 +225,10 @@ export const routeToExpressHandler = < await acc; - // @ts-expect-error - const requestMetadata = await plugin.value.request(request); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- augmentation + const pluginRequest = plugin.value.request as unknown as (...args: HandlerArguments) => MaybeAsync; + + const requestMetadata = await pluginRequest(request, toolkit); if (requestMetadata) { // @ts-expect-error // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ok @@ -296,9 +307,7 @@ function createRequestToolkitFor({ async setCookie(name, value, options = {}) { const { encrypted, secret, ...cookieOptions } = deepMerge(options, appOptions.cookies); - if (encrypted && secret.length !== 32) { - throw new Error('`options.cookies.secret` must be exactly 32 characters long.'); - } + invariant(!encrypted || secret.length === 32, '`options.cookies.secret` must be exactly 32 characters long.'); const cookieValue = encrypted ? await seal({ value, secret }) : value; res.cookie(name, cookieValue, { ...cookieOptions, signed: false }); @@ -307,9 +316,12 @@ function createRequestToolkitFor({ const cookieOptions = deepMerge(options, appOptions.cookies); res.clearCookie(name, cookieOptions); }, - setStatus(statusCode: HttpStatusCode) { + setStatus(statusCode) { res.locals[CUSTOM_STATUS_CODE] = statusCode; }, + setHeader(name, value) { + res.setHeader(name, value); + }, }; return toolkit; diff --git a/src/modules/shared.ts b/src/modules/shared.ts index 447d986..1901402 100644 --- a/src/modules/shared.ts +++ b/src/modules/shared.ts @@ -44,6 +44,7 @@ export interface TypeOfWebRequestToolkit { setCookie(name: string, value: string, options?: SetCookieOptions): MaybeAsync; removeCookie(name: string, options?: SetCookieOptions): MaybeAsync; setStatus(statusCode: HttpStatusCode): MaybeAsync; + setHeader(headerName: string, value: string): MaybeAsync; } export interface SetCookieOptions { @@ -168,3 +169,21 @@ export interface TypeOfWebApp { } export type TypeOfWebRoute = Parameters[0]; + +export type HandlerArguments = Parameters; + +// prettier-ignore +type Hours = `${0|1}${0|1|2|3|4|5|6|7|8|9}`|`2${0|1|2|3}`; +// type Minutes = `${0|1|2|3|4|5}${0|1|2|3|4|5|6|7|8|9}`; +type Minutes = '00' | '15' | '30' | '45'; + +type ExpireAt = `${Hours}:${Minutes}`; +export type TypeOfWebCacheConfig = + | { + readonly expireIn: number; + readonly expireAt?: undefined; + } + | { + readonly expireIn?: undefined; + readonly expireAt: ExpireAt; + }; diff --git a/src/utils/encryptCookies.ts b/src/utils/encryptCookies.ts index bec9722..4da6c26 100644 --- a/src/utils/encryptCookies.ts +++ b/src/utils/encryptCookies.ts @@ -9,6 +9,8 @@ import Crypto from 'crypto'; import Util from 'util'; +import { invariant } from './errors'; + const asyncPbkdf2 = Util.promisify(Crypto.pbkdf2); const PREFIX = 'Fe26.2' as const; @@ -55,9 +57,7 @@ export const seal = async ({ readonly secret: string; readonly ttl?: number; }) => { - if (secret.length !== KEY_LEN) { - throw new Error(`Secret must be exactly ${KEY_LEN} characters long!`); - } + invariant(secret.length === KEY_LEN, `Secret must be exactly ${KEY_LEN} characters long!`); const key = await generateKey(secret); @@ -81,27 +81,19 @@ export const seal = async ({ export const unseal = async ({ sealed, secret }: { readonly sealed: string; readonly secret: string }) => { const sealedContent = sealed.split(SEPARATOR); - if (sealedContent.length !== SEALED_CONTENT_LENGTH) { - throw new Error('Cannot unseal: Incorrect data format.'); - } + invariant(sealedContent.length === SEALED_CONTENT_LENGTH, 'Cannot unseal: Incorrect data format.'); const [prefix, _passwordId, keySalt64, ivB64, encryptedB64, expiration, hmacSaltB64, hmacDigest] = // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- must assert sealedContent as unknown as SealedContent; - if (prefix !== PREFIX) { - throw new Error('Cannot unseal: Unsupported version.'); - } + invariant(prefix === PREFIX, 'Cannot unseal: Unsupported version.'); if (expiration) { - if (!Number.isInteger(Number(expiration))) { - throw new Error('Cannot unseal: Invalid expiration'); - } + invariant(Number.isInteger(Number(expiration)), 'Cannot unseal: Invalid expiration'); const exp = Number.parseInt(expiration, 10); - if (exp <= Date.now()) { - throw new Error('Cannot unseal: Expired seal'); - } + invariant(exp > Date.now(), 'Cannot unseal: Expired seal'); } const baseContent: BaseContent = [PREFIX, '', keySalt64, ivB64, encryptedB64, expiration]; @@ -110,9 +102,7 @@ export const unseal = async ({ sealed, secret }: { readonly sealed: string; read const hmacSalt = base64urlDecode(hmacSaltB64); const mac = await hmacWithPassword(secret, baseString, hmacSalt); - if (!timingSafeEqual(mac.digest, hmacDigest)) { - throw new Error('Cannot unseal: Incorrect hmac seal value'); - } + invariant(timingSafeEqual(mac.digest, hmacDigest), 'Cannot unseal: Incorrect hmac seal value'); const encrypted = base64urlDecode(encryptedB64); const iv = base64urlDecode(ivB64); diff --git a/src/utils/errors.ts b/src/utils/errors.ts index ca78976..bb1b391 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -39,3 +39,17 @@ export class HttpError extends Error implements StatusError { export const isStatusError = (err: unknown): err is StatusError => { return typeof err === 'object' && !!err && 'statusCode' in err; }; + +interface ErrorCtor { + new (...args: ConstructorParameters): T; +} + +export function invariant( + predicate: unknown, + message: string, + ErrorConstructor: ErrorCtor = Error, +): asserts predicate { + if (!predicate) { + throw new ErrorConstructor(message); + } +} diff --git a/src/utils/ms.ts b/src/utils/ms.ts new file mode 100644 index 0000000..cf75c48 --- /dev/null +++ b/src/utils/ms.ts @@ -0,0 +1,26 @@ +import { invariant } from './errors'; + +const units = [ + { value: 1, dictionary: ['ms', 'millisecond', 'milliseconds'] }, + { value: 1000, dictionary: ['s', 'sec', 'second', 'seconds'] }, + { value: 1000 * 60, dictionary: ['m', 'min', 'minute', 'minutes'] }, + { value: 1000 * 60 * 60, dictionary: ['h', 'hour', 'hours'] }, + { value: 1000 * 60 * 60 * 24, dictionary: ['d', 'day', 'days'] }, +] as const; + +type Unit = typeof units[number]['dictionary'][number]; +type ValidArg = `${number} ${Unit}`; + +export const ms = (arg: ValidArg): number => { + const [value, unit] = arg.split(/\s+/); + invariant(value != null, 'Missing value'); + invariant(unit != null, 'Missing unit'); + + const parsedValue = Number.parseFloat(value); + invariant(!Number.isNaN(parsedValue), 'Not a valid number'); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ok + const config = units.find((config) => (config.dictionary as readonly string[]).includes(unit)); + invariant(config, `Not a valid unit ${unit}`); + return parsedValue * config.value; +}; diff --git a/src/utils/serializeObject.ts b/src/utils/serializeObject.ts new file mode 100644 index 0000000..355a2b6 --- /dev/null +++ b/src/utils/serializeObject.ts @@ -0,0 +1,15 @@ +import type { Json, JsonObject } from './types'; + +const isObject = (val: unknown): val is JsonObject => typeof val === 'object' && !!val && !Array.isArray(val); + +export const stableJsonStringify = (arg: Json): string => { + return JSON.stringify(isObject(arg) ? sortObjProperties(arg) : arg); +}; + +const sortObjProperties = (arg: T): T => { + return Object.fromEntries( + Object.entries(arg) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([key, val]) => (isObject(val) ? [key, sortObjProperties(val)] : [key, val])), + ); +}; diff --git a/src/utils/types.ts b/src/utils/types.ts index bc324dd..924b4e0 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -6,6 +6,10 @@ export type DeepPartial = { readonly [P in keyof T]?: T[P] extends AnyObject ? DeepPartial : T[P]; }; +export type AnyFunction = (...args: readonly any[]) => any; + +export type AnyAsyncFunction = (...args: readonly any[]) => Promise; + export type Pretty = X extends AnyObject | readonly unknown[] ? { readonly [K in keyof X]: X[K]; diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..3b7662b --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1 @@ +export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/yarn.lock b/yarn.lock index a46b62c..8a56922 100644 --- a/yarn.lock +++ b/yarn.lock @@ -762,6 +762,11 @@ "@types/connect" "*" "@types/node" "*" +"@types/cache-manager@3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@types/cache-manager/-/cache-manager-3.4.0.tgz#414136ea3807a8cd071b8f20370c5df5dbffd382" + integrity sha512-XVbn2HS+O+Mk2SKRCjr01/8oD5p2Tv1fxxdBqJ0+Cl+UBNiz0WVY5rusHpMGx+qF6Vc2pnRwPVwSKbGaDApCpw== + "@types/connect@*": version "3.4.34" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.34.tgz#170a40223a6d666006d93ca128af2beb1d9b1901" @@ -1300,7 +1305,7 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== -async@^3.0.1: +async@3.2.0, async@^3.0.1: version "3.2.0" resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== @@ -1537,6 +1542,15 @@ cacache@^15.0.5, cacache@^15.2.0: tar "^6.0.2" unique-filename "^1.1.1" +cache-manager@3.4.4: + version "3.4.4" + resolved "https://registry.yarnpkg.com/cache-manager/-/cache-manager-3.4.4.tgz#c69814763d3f3031395ae0d3a9a9296a91602226" + integrity sha512-oayy7ukJqNlRUYNUfQBwGOLilL0X5q7GpuaF19Yqwo6qdx49OoTZKRIF5qbbr+Ru8mlTvOpvnMvVq6vw72pOPg== + dependencies: + async "3.2.0" + lodash "^4.17.21" + lru-cache "6.0.0" + cacheable-request@^6.0.0: version "6.1.0" resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" @@ -4243,7 +4257,7 @@ lowercase-keys@^2.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== -lru-cache@^6.0.0: +lru-cache@6.0.0, lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==