mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-19 05:46:04 +00:00
public commit
This commit is contained in:
127
app/src/core/cache/adapters/CloudflareKvCache.ts
vendored
Normal file
127
app/src/core/cache/adapters/CloudflareKvCache.ts
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { ICacheItem, ICachePool } from "../cache-interface";
|
||||
|
||||
export class CloudflareKVCachePool<Data = any> implements ICachePool<Data> {
|
||||
constructor(private namespace: KVNamespace) {}
|
||||
|
||||
supports = () => ({
|
||||
metadata: true,
|
||||
clear: false,
|
||||
});
|
||||
|
||||
async get(key: string): Promise<ICacheItem<Data>> {
|
||||
const result = await this.namespace.getWithMetadata<any>(key);
|
||||
const hit = result.value !== null && typeof result.value !== "undefined";
|
||||
// Assuming metadata is not supported directly;
|
||||
// you may adjust if Cloudflare KV supports it in future.
|
||||
return new CloudflareKVCacheItem(key, result.value ?? undefined, hit, result.metadata) as any;
|
||||
}
|
||||
|
||||
async getMany(keys: string[] = []): Promise<Map<string, ICacheItem<Data>>> {
|
||||
const items = new Map<string, ICacheItem<Data>>();
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const item = await this.get(key);
|
||||
items.set(key, item);
|
||||
}),
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
const data = await this.namespace.get(key);
|
||||
return data !== null;
|
||||
}
|
||||
|
||||
async clear(): Promise<boolean> {
|
||||
// Cloudflare KV does not support clearing all keys in one operation
|
||||
return false;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
await this.namespace.delete(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteMany(keys: string[]): Promise<boolean> {
|
||||
const results = await Promise.all(keys.map((key) => this.delete(key)));
|
||||
return results.every((result) => result);
|
||||
}
|
||||
|
||||
async save(item: CloudflareKVCacheItem<Data>): Promise<boolean> {
|
||||
await this.namespace.put(item.key(), (await item.value()) as string, {
|
||||
expirationTtl: item._expirationTtl,
|
||||
metadata: item.metadata(),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
value: any,
|
||||
options?: { ttl?: number; expiresAt?: Date; metadata?: Record<string, string> },
|
||||
): Promise<boolean> {
|
||||
const item = new CloudflareKVCacheItem(key, value, true, options?.metadata);
|
||||
|
||||
if (options?.expiresAt) item.expiresAt(options.expiresAt);
|
||||
if (options?.ttl) item.expiresAfter(options.ttl);
|
||||
|
||||
return await this.save(item);
|
||||
}
|
||||
}
|
||||
|
||||
export class CloudflareKVCacheItem<Data = any> implements ICacheItem<Data> {
|
||||
_expirationTtl: number | undefined;
|
||||
|
||||
constructor(
|
||||
private _key: string,
|
||||
private data: Data | undefined,
|
||||
private _hit: boolean = false,
|
||||
private _metadata: Record<string, string> = {},
|
||||
) {}
|
||||
|
||||
key(): string {
|
||||
return this._key;
|
||||
}
|
||||
|
||||
value(): Data | undefined {
|
||||
if (this.data) {
|
||||
try {
|
||||
return JSON.parse(this.data as string);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return this.data ?? undefined;
|
||||
}
|
||||
|
||||
metadata(): Record<string, string> {
|
||||
return this._metadata;
|
||||
}
|
||||
|
||||
hit(): boolean {
|
||||
return this._hit;
|
||||
}
|
||||
|
||||
set(value: Data, metadata: Record<string, string> = {}): this {
|
||||
this.data = value;
|
||||
this._metadata = metadata;
|
||||
return this;
|
||||
}
|
||||
|
||||
expiresAt(expiration: Date | null): this {
|
||||
// Cloudflare KV does not support specific date expiration; calculate ttl instead.
|
||||
if (expiration) {
|
||||
const now = new Date();
|
||||
const ttl = (expiration.getTime() - now.getTime()) / 1000;
|
||||
return this.expiresAfter(Math.max(0, Math.floor(ttl)));
|
||||
}
|
||||
return this.expiresAfter(null);
|
||||
}
|
||||
|
||||
expiresAfter(time: number | null): this {
|
||||
// Dummy implementation as Cloudflare KV requires setting expiration during PUT operation.
|
||||
// This method will be effectively implemented in the Cache Pool save methods.
|
||||
this._expirationTtl = time ?? undefined;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
139
app/src/core/cache/adapters/MemoryCache.ts
vendored
Normal file
139
app/src/core/cache/adapters/MemoryCache.ts
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { ICacheItem, ICachePool } from "../cache-interface";
|
||||
|
||||
export class MemoryCache<Data = any> implements ICachePool<Data> {
|
||||
private cache: Map<string, MemoryCacheItem<Data>> = new Map();
|
||||
private maxSize?: number;
|
||||
|
||||
constructor(options?: { maxSize?: number }) {
|
||||
this.maxSize = options?.maxSize;
|
||||
}
|
||||
|
||||
supports = () => ({
|
||||
metadata: true,
|
||||
clear: true
|
||||
});
|
||||
|
||||
async get(key: string): Promise<MemoryCacheItem<Data>> {
|
||||
if (!this.cache.has(key)) {
|
||||
// use undefined to denote a miss initially
|
||||
return new MemoryCacheItem<Data>(key, undefined!);
|
||||
}
|
||||
return this.cache.get(key)!;
|
||||
}
|
||||
|
||||
async getMany(keys: string[] = []): Promise<Map<string, MemoryCacheItem<Data>>> {
|
||||
const items = new Map<string, MemoryCacheItem<Data>>();
|
||||
for (const key of keys) {
|
||||
items.set(key, await this.get(key));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return this.cache.has(key) && this.cache.get(key)!.hit();
|
||||
}
|
||||
|
||||
async clear(): Promise<boolean> {
|
||||
this.cache.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
|
||||
async deleteMany(keys: string[]): Promise<boolean> {
|
||||
let success = true;
|
||||
for (const key of keys) {
|
||||
if (!this.delete(key)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
async save(item: MemoryCacheItem<Data>): Promise<boolean> {
|
||||
this.checkSizeAndPurge();
|
||||
this.cache.set(item.key(), item);
|
||||
return true;
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
value: Data,
|
||||
options: { expiresAt?: Date; ttl?: number; metadata?: Record<string, string> } = {}
|
||||
): Promise<boolean> {
|
||||
const item = await this.get(key);
|
||||
item.set(value, options.metadata || {});
|
||||
if (options.expiresAt) {
|
||||
item.expiresAt(options.expiresAt);
|
||||
} else if (typeof options.ttl === "number") {
|
||||
item.expiresAfter(options.ttl);
|
||||
}
|
||||
return this.save(item);
|
||||
}
|
||||
|
||||
private checkSizeAndPurge(): void {
|
||||
if (!this.maxSize) return;
|
||||
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
// Implement logic to purge items, e.g., LRU (Least Recently Used)
|
||||
// For simplicity, clear the oldest item inserted
|
||||
const keyToDelete = this.cache.keys().next().value;
|
||||
this.cache.delete(keyToDelete!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MemoryCacheItem<Data = any> implements ICacheItem<Data> {
|
||||
private _key: string;
|
||||
private _value: Data | undefined;
|
||||
private expiration: Date | null = null;
|
||||
private _metadata: Record<string, string> = {};
|
||||
|
||||
constructor(key: string, value: Data, metadata: Record<string, string> = {}) {
|
||||
this._key = key;
|
||||
this.set(value, metadata);
|
||||
}
|
||||
|
||||
key(): string {
|
||||
return this._key;
|
||||
}
|
||||
|
||||
metadata(): Record<string, string> {
|
||||
return this._metadata;
|
||||
}
|
||||
|
||||
value(): Data | undefined {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
hit(): boolean {
|
||||
if (this.expiration !== null && new Date() > this.expiration) {
|
||||
return false;
|
||||
}
|
||||
return this.value() !== undefined;
|
||||
}
|
||||
|
||||
set(value: Data, metadata: Record<string, string> = {}): this {
|
||||
this._value = value;
|
||||
this._metadata = metadata;
|
||||
return this;
|
||||
}
|
||||
|
||||
expiresAt(expiration: Date | null): this {
|
||||
this.expiration = expiration;
|
||||
return this;
|
||||
}
|
||||
|
||||
expiresAfter(time: number | null): this {
|
||||
if (typeof time === "number") {
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setSeconds(expirationDate.getSeconds() + time);
|
||||
this.expiration = expirationDate;
|
||||
} else {
|
||||
this.expiration = null;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
178
app/src/core/cache/cache-interface.ts
vendored
Normal file
178
app/src/core/cache/cache-interface.ts
vendored
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* CacheItem defines an interface for interacting with objects inside a cache.
|
||||
* based on https://www.php-fig.org/psr/psr-6/
|
||||
*/
|
||||
export interface ICacheItem<Data = any> {
|
||||
/**
|
||||
* Returns the key for the current cache item.
|
||||
*
|
||||
* The key is loaded by the Implementing Library, but should be available to
|
||||
* the higher level callers when needed.
|
||||
*
|
||||
* @returns The key string for this cache item.
|
||||
*/
|
||||
key(): string;
|
||||
|
||||
/**
|
||||
* Retrieves the value of the item from the cache associated with this object's key.
|
||||
*
|
||||
* The value returned must be identical to the value originally stored by set().
|
||||
*
|
||||
* If isHit() returns false, this method MUST return null. Note that null
|
||||
* is a legitimate cached value, so the isHit() method SHOULD be used to
|
||||
* differentiate between "null value was found" and "no value was found."
|
||||
*
|
||||
* @returns The value corresponding to this cache item's key, or undefined if not found.
|
||||
*/
|
||||
value(): Data | undefined;
|
||||
|
||||
/**
|
||||
* Retrieves the metadata of the item from the cache associated with this object's key.
|
||||
*/
|
||||
metadata(): Record<string, string>;
|
||||
|
||||
/**
|
||||
* Confirms if the cache item lookup resulted in a cache hit.
|
||||
*
|
||||
* Note: This method MUST NOT have a race condition between calling isHit()
|
||||
* and calling get().
|
||||
*
|
||||
* @returns True if the request resulted in a cache hit. False otherwise.
|
||||
*/
|
||||
hit(): boolean;
|
||||
|
||||
/**
|
||||
* Sets the value represented by this cache item.
|
||||
*
|
||||
* The value argument may be any item that can be serialized by PHP,
|
||||
* although the method of serialization is left up to the Implementing
|
||||
* Library.
|
||||
*
|
||||
* @param value The serializable value to be stored.
|
||||
* @param metadata The metadata to be associated with the item.
|
||||
* @returns The invoked object.
|
||||
*/
|
||||
set(value: Data, metadata?: Record<string, string>): this;
|
||||
|
||||
/**
|
||||
* Sets the expiration time for this cache item.
|
||||
*
|
||||
* @param expiration The point in time after which the item MUST be considered expired.
|
||||
* If null is passed explicitly, a default value MAY be used. If none is set,
|
||||
* the value should be stored permanently or for as long as the
|
||||
* implementation allows.
|
||||
* @returns The called object.
|
||||
*/
|
||||
expiresAt(expiration: Date | null): this;
|
||||
|
||||
/**
|
||||
* Sets the expiration time for this cache item.
|
||||
*
|
||||
* @param time The period of time from the present after which the item MUST be considered
|
||||
* expired. An integer parameter is understood to be the time in seconds until
|
||||
* expiration. If null is passed explicitly, a default value MAY be used.
|
||||
* If none is set, the value should be stored permanently or for as long as the
|
||||
* implementation allows.
|
||||
* @returns The called object.
|
||||
*/
|
||||
expiresAfter(time: number | null): this;
|
||||
}
|
||||
|
||||
/**
|
||||
* CachePool generates CacheItem objects.
|
||||
* based on https://www.php-fig.org/psr/psr-6/
|
||||
*/
|
||||
export interface ICachePool<Data = any> {
|
||||
supports(): {
|
||||
metadata: boolean;
|
||||
clear: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a Cache Item representing the specified key.
|
||||
* This method must always return a CacheItemInterface object, even in case of
|
||||
* a cache miss. It MUST NOT return null.
|
||||
*
|
||||
* @param key The key for which to return the corresponding Cache Item.
|
||||
* @throws Error If the key string is not a legal value an Error MUST be thrown.
|
||||
* @returns The corresponding Cache Item.
|
||||
*/
|
||||
get(key: string): Promise<ICacheItem<Data>>;
|
||||
|
||||
/**
|
||||
* Returns a traversable set of cache items.
|
||||
*
|
||||
* @param keys An indexed array of keys of items to retrieve.
|
||||
* @throws Error If any of the keys in keys are not a legal value an Error MUST be thrown.
|
||||
* @returns A traversable collection of Cache Items keyed by the cache keys of
|
||||
* each item. A Cache item will be returned for each key, even if that
|
||||
* key is not found. However, if no keys are specified then an empty
|
||||
* traversable MUST be returned instead.
|
||||
*/
|
||||
getMany(keys?: string[]): Promise<Map<string, ICacheItem<Data>>>;
|
||||
|
||||
/**
|
||||
* Confirms if the cache contains specified cache item.
|
||||
*
|
||||
* Note: This method MAY avoid retrieving the cached value for performance reasons.
|
||||
* This could result in a race condition with CacheItemInterface.get(). To avoid
|
||||
* such situation use CacheItemInterface.isHit() instead.
|
||||
*
|
||||
* @param key The key for which to check existence.
|
||||
* @throws Error If the key string is not a legal value an Error MUST be thrown.
|
||||
* @returns True if item exists in the cache, false otherwise.
|
||||
*/
|
||||
has(key: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Deletes all items in the pool.
|
||||
* @returns True if the pool was successfully cleared. False if there was an error.
|
||||
*/
|
||||
clear(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Removes the item from the pool.
|
||||
*
|
||||
* @param key The key to delete.
|
||||
* @throws Error If the key string is not a legal value an Error MUST be thrown.
|
||||
* @returns True if the item was successfully removed. False if there was an error.
|
||||
*/
|
||||
delete(key: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Removes multiple items from the pool.
|
||||
*
|
||||
* @param keys An array of keys that should be removed from the pool.
|
||||
* @throws Error If any of the keys in keys are not a legal value an Error MUST be thrown.
|
||||
* @returns True if the items were successfully removed. False if there was an error.
|
||||
*/
|
||||
deleteMany(keys: string[]): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Persists a cache item immediately.
|
||||
*
|
||||
* @param item The cache item to save.
|
||||
* @returns True if the item was successfully persisted. False if there was an error.
|
||||
*/
|
||||
save(item: ICacheItem<Data>): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Persists any deferred cache items.
|
||||
* @returns True if all not-yet-saved items were successfully saved or there were none. False otherwise.
|
||||
*/
|
||||
put(
|
||||
key: string,
|
||||
value: any,
|
||||
options?: { expiresAt?: Date; metadata?: Record<string, string> },
|
||||
): Promise<boolean>;
|
||||
put(
|
||||
key: string,
|
||||
value: any,
|
||||
options?: { ttl?: number; metadata?: Record<string, string> },
|
||||
): Promise<boolean>;
|
||||
put(
|
||||
key: string,
|
||||
value: any,
|
||||
options?: ({ ttl?: number } | { expiresAt?: Date }) & { metadata?: Record<string, string> },
|
||||
): Promise<boolean>;
|
||||
}
|
||||
96
app/src/core/clients/aws/AwsClient.ts
Normal file
96
app/src/core/clients/aws/AwsClient.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { AwsClient as Aws4fetchClient } from "aws4fetch";
|
||||
import { objectKeysPascalToKebab } from "../../utils/objects";
|
||||
import { xmlToObject } from "../../utils/xml";
|
||||
|
||||
type Aws4fetchClientConfig = ConstructorParameters<typeof Aws4fetchClient>[0];
|
||||
type AwsClientConfig = {
|
||||
responseType?: "xml" | "json";
|
||||
responseKeysToUpper?: boolean;
|
||||
convertParams?: "pascalToKebab";
|
||||
};
|
||||
|
||||
export class AwsClient extends Aws4fetchClient {
|
||||
readonly #options: AwsClientConfig;
|
||||
|
||||
constructor(aws4fetchConfig: Aws4fetchClientConfig, options?: AwsClientConfig) {
|
||||
super(aws4fetchConfig);
|
||||
this.#options = options ?? {
|
||||
responseType: "json",
|
||||
};
|
||||
}
|
||||
|
||||
protected convertParams(params: Record<string, any>): Record<string, any> {
|
||||
switch (this.#options.convertParams) {
|
||||
case "pascalToKebab":
|
||||
return objectKeysPascalToKebab(params);
|
||||
default:
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
getUrl(path: string = "/", searchParamsObj: Record<string, any> = {}): string {
|
||||
//console.log("super:getUrl", path, searchParamsObj);
|
||||
const url = new URL(path);
|
||||
const converted = this.convertParams(searchParamsObj);
|
||||
Object.entries(converted).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value as any);
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
protected updateKeysRecursively(obj: any, direction: "toUpperCase" | "toLowerCase") {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => this.updateKeysRecursively(item, direction));
|
||||
}
|
||||
|
||||
if (typeof obj === "object") {
|
||||
return Object.keys(obj).reduce(
|
||||
(acc, key) => {
|
||||
// only if key doesn't have any whitespaces
|
||||
let newKey = key;
|
||||
if (key.indexOf(" ") === -1) {
|
||||
newKey = key.charAt(0)[direction]() + key.slice(1);
|
||||
}
|
||||
acc[newKey] = this.updateKeysRecursively(obj[key], direction);
|
||||
return acc;
|
||||
},
|
||||
{} as { [key: string]: any },
|
||||
);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
async fetchJson<T extends Record<string, any>>(
|
||||
input: RequestInfo,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await this.fetch(input, init);
|
||||
|
||||
if (this.#options.responseType === "xml") {
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(body);
|
||||
}
|
||||
|
||||
const raw = await response.text();
|
||||
//console.log("raw", raw);
|
||||
//console.log(JSON.stringify(xmlToObject(raw), null, 2));
|
||||
return xmlToObject(raw) as T;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json<{ message: string }>();
|
||||
throw new Error(body.message);
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as T;
|
||||
if (this.#options.responseKeysToUpper) {
|
||||
return this.updateKeysRecursively(raw, "toUpperCase");
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
12
app/src/core/config.ts
Normal file
12
app/src/core/config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* These are package global defaults.
|
||||
*/
|
||||
import type { Generated } from "kysely";
|
||||
|
||||
export type PrimaryFieldType = number | Generated<number>;
|
||||
|
||||
export const config = {
|
||||
data: {
|
||||
default_primary_field: "id"
|
||||
}
|
||||
} as const;
|
||||
27
app/src/core/env.ts
Normal file
27
app/src/core/env.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
type TURSO_DB = {
|
||||
url: string;
|
||||
authToken: string;
|
||||
};
|
||||
|
||||
export type Env = {
|
||||
__STATIC_CONTENT: Fetcher;
|
||||
ENVIRONMENT: string;
|
||||
CACHE: KVNamespace;
|
||||
|
||||
// db
|
||||
DB_DATA: TURSO_DB;
|
||||
DB_SCHEMA: TURSO_DB;
|
||||
|
||||
// storage
|
||||
STORAGE: { access_key: string; secret_access_key: string; url: string };
|
||||
BUCKET: R2Bucket;
|
||||
};
|
||||
|
||||
export function isDebug(): boolean {
|
||||
try {
|
||||
// @ts-expect-error - this is a global variable in dev
|
||||
return __isDev === "1" || __isDev === 1;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
37
app/src/core/errors.ts
Normal file
37
app/src/core/errors.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export class Exception extends Error {
|
||||
code = 400;
|
||||
override name = "Exception";
|
||||
|
||||
constructor(message: string, code?: number) {
|
||||
super(message);
|
||||
if (code) {
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.message,
|
||||
type: this.name
|
||||
//message: this.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BkndError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public details?: Record<string, any>,
|
||||
public type?: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type ?? "unknown",
|
||||
message: this.message,
|
||||
details: this.details
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/src/core/events/Event.ts
Normal file
21
app/src/core/events/Event.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export abstract class Event<Params = any> {
|
||||
/**
|
||||
* Unique event slug
|
||||
* Must be static, because registering events is done by class
|
||||
*/
|
||||
static slug: string = "untitled-event";
|
||||
params: Params;
|
||||
|
||||
constructor(params: Params) {
|
||||
this.params = params;
|
||||
}
|
||||
}
|
||||
|
||||
// @todo: current workaround: potentially there is none and that's the way
|
||||
export class NoParamEvent extends Event<null> {
|
||||
static override slug: string = "noparam-event";
|
||||
|
||||
constructor() {
|
||||
super(null);
|
||||
}
|
||||
}
|
||||
22
app/src/core/events/EventListener.ts
Normal file
22
app/src/core/events/EventListener.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Event } from "./Event";
|
||||
import type { EventClass } from "./EventManager";
|
||||
|
||||
export const ListenerModes = ["sync", "async"] as const;
|
||||
export type ListenerMode = (typeof ListenerModes)[number];
|
||||
|
||||
export type ListenerHandler<E extends Event = Event> = (
|
||||
event: E,
|
||||
slug: string,
|
||||
) => Promise<void> | void;
|
||||
|
||||
export class EventListener<E extends Event = Event> {
|
||||
mode: ListenerMode = "async";
|
||||
event: EventClass;
|
||||
handler: ListenerHandler<E>;
|
||||
|
||||
constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") {
|
||||
this.event = event;
|
||||
this.handler = handler;
|
||||
this.mode = mode;
|
||||
}
|
||||
}
|
||||
151
app/src/core/events/EventManager.ts
Normal file
151
app/src/core/events/EventManager.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { Event } from "./Event";
|
||||
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
|
||||
|
||||
export interface EmitsEvents {
|
||||
emgr: EventManager;
|
||||
}
|
||||
|
||||
export type EventClass = {
|
||||
new (params: any): Event;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export class EventManager<
|
||||
RegisteredEvents extends Record<string, EventClass> = Record<string, EventClass>
|
||||
> {
|
||||
protected events: EventClass[] = [];
|
||||
protected listeners: EventListener[] = [];
|
||||
|
||||
constructor(events?: RegisteredEvents, listeners?: EventListener[]) {
|
||||
if (events) {
|
||||
this.registerEvents(events);
|
||||
}
|
||||
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
this.addListener(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearEvents() {
|
||||
this.events = [];
|
||||
return this;
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.clearEvents();
|
||||
this.listeners = [];
|
||||
return this;
|
||||
}
|
||||
|
||||
get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } {
|
||||
// proxy class to access events
|
||||
return new Proxy(this, {
|
||||
get: (_, prop: string) => {
|
||||
return this.events.find((e) => e.slug === prop);
|
||||
}
|
||||
}) as any;
|
||||
}
|
||||
|
||||
eventExists(slug: string): boolean;
|
||||
eventExists(event: EventClass | Event): boolean;
|
||||
eventExists(eventOrSlug: EventClass | Event | string): boolean {
|
||||
let slug: string;
|
||||
|
||||
if (typeof eventOrSlug === "string") {
|
||||
slug = eventOrSlug;
|
||||
} else {
|
||||
// @ts-expect-error
|
||||
slug = eventOrSlug.constructor?.slug ?? eventOrSlug.slug;
|
||||
/*eventOrSlug instanceof Event
|
||||
? // @ts-expect-error slug is static
|
||||
eventOrSlug.constructor.slug
|
||||
: eventOrSlug.slug;*/
|
||||
}
|
||||
|
||||
return !!this.events.find((e) => slug === e.slug);
|
||||
}
|
||||
|
||||
protected throwIfEventNotRegistered(event: EventClass) {
|
||||
if (!this.eventExists(event)) {
|
||||
throw new Error(`Event "${event.slug}" not registered`);
|
||||
}
|
||||
}
|
||||
|
||||
registerEvent(event: EventClass, silent: boolean = false) {
|
||||
if (this.eventExists(event)) {
|
||||
if (silent) {
|
||||
return this;
|
||||
}
|
||||
|
||||
throw new Error(`Event "${event.name}" already registered.`);
|
||||
}
|
||||
|
||||
this.events.push(event);
|
||||
return this;
|
||||
}
|
||||
|
||||
registerEvents(eventObjects: Record<string, EventClass>): this;
|
||||
registerEvents(eventArray: EventClass[]): this;
|
||||
registerEvents(objectOrArray: Record<string, EventClass> | EventClass[]): this {
|
||||
const events =
|
||||
typeof objectOrArray === "object" ? Object.values(objectOrArray) : objectOrArray;
|
||||
events.forEach((event) => this.registerEvent(event, true));
|
||||
return this;
|
||||
}
|
||||
|
||||
addListener(listener: EventListener) {
|
||||
this.throwIfEventNotRegistered(listener.event);
|
||||
|
||||
this.listeners.push(listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
onEvent<ActualEvent extends EventClass, Instance extends InstanceType<ActualEvent>>(
|
||||
event: ActualEvent,
|
||||
handler: ListenerHandler<Instance>,
|
||||
mode: ListenerMode = "async"
|
||||
) {
|
||||
this.throwIfEventNotRegistered(event);
|
||||
|
||||
const listener = new EventListener(event, handler, mode);
|
||||
this.addListener(listener as any);
|
||||
}
|
||||
|
||||
on<Params = any>(
|
||||
slug: string,
|
||||
handler: ListenerHandler<Event<Params>>,
|
||||
mode: ListenerMode = "async"
|
||||
) {
|
||||
const event = this.events.find((e) => e.slug === slug);
|
||||
if (!event) {
|
||||
throw new Error(`Event "${slug}" not registered`);
|
||||
}
|
||||
|
||||
this.onEvent(event, handler, mode);
|
||||
}
|
||||
|
||||
onAny(handler: ListenerHandler<Event<unknown>>, mode: ListenerMode = "async") {
|
||||
this.events.forEach((event) => this.onEvent(event, handler, mode));
|
||||
}
|
||||
|
||||
async emit(event: Event) {
|
||||
// @ts-expect-error slug is static
|
||||
const slug = event.constructor.slug;
|
||||
if (!this.eventExists(event)) {
|
||||
throw new Error(`Event "${slug}" not registered`);
|
||||
}
|
||||
|
||||
const listeners = this.listeners.filter((listener) => listener.event.slug === slug);
|
||||
//console.log("---!-- emitting", slug, listeners.length);
|
||||
|
||||
for (const listener of listeners) {
|
||||
if (listener.mode === "sync") {
|
||||
await listener.handler(event, listener.event.slug);
|
||||
} else {
|
||||
listener.handler(event, listener.event.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
app/src/core/events/index.ts
Normal file
8
app/src/core/events/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { Event, NoParamEvent } from "./Event";
|
||||
export {
|
||||
EventListener,
|
||||
ListenerModes,
|
||||
type ListenerMode,
|
||||
type ListenerHandler,
|
||||
} from "./EventListener";
|
||||
export { EventManager, type EmitsEvents, type EventClass } from "./EventManager";
|
||||
28
app/src/core/index.ts
Normal file
28
app/src/core/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export { Endpoint, type RequestResponse, type Middleware } from "./server/Endpoint";
|
||||
export { zValidator } from "./server/lib/zValidator";
|
||||
export { tbValidator } from "./server/lib/tbValidator";
|
||||
export { Exception, BkndError } from "./errors";
|
||||
export { isDebug } from "./env";
|
||||
export { type PrimaryFieldType, config } from "./config";
|
||||
export { AwsClient } from "./clients/aws/AwsClient";
|
||||
export {
|
||||
SimpleRenderer,
|
||||
type TemplateObject,
|
||||
type TemplateTypes,
|
||||
type SimpleRendererOptions
|
||||
} from "./template/SimpleRenderer";
|
||||
export { Controller, type ClassController } from "./server/Controller";
|
||||
export { SchemaObject } from "./object/SchemaObject";
|
||||
export { DebugLogger } from "./utils/DebugLogger";
|
||||
export { Permission } from "./security/Permission";
|
||||
export {
|
||||
exp,
|
||||
makeValidator,
|
||||
type FilterQuery,
|
||||
type Primitive,
|
||||
isPrimitive,
|
||||
type TExpression,
|
||||
type BooleanLike,
|
||||
isBooleanLike
|
||||
} from "./object/query/query";
|
||||
export { Registry, type Constructor } from "./registry/Registry";
|
||||
199
app/src/core/object/SchemaObject.ts
Normal file
199
app/src/core/object/SchemaObject.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { cloneDeep, get, has, mergeWith, omit, set } from "lodash-es";
|
||||
import {
|
||||
Default,
|
||||
type Static,
|
||||
type TObject,
|
||||
getFullPathKeys,
|
||||
mark,
|
||||
parse,
|
||||
stripMark
|
||||
} from "../utils";
|
||||
|
||||
export type SchemaObjectOptions<Schema extends TObject> = {
|
||||
onUpdate?: (config: Static<Schema>) => void | Promise<void>;
|
||||
restrictPaths?: string[];
|
||||
overwritePaths?: (RegExp | string)[];
|
||||
forceParse?: boolean;
|
||||
};
|
||||
|
||||
export class SchemaObject<Schema extends TObject> {
|
||||
private readonly _default: Partial<Static<Schema>>;
|
||||
private _value: Static<Schema>;
|
||||
private _config: Static<Schema>;
|
||||
private _restriction_bypass: boolean = false;
|
||||
|
||||
constructor(
|
||||
private _schema: Schema,
|
||||
initial?: Partial<Static<Schema>>,
|
||||
private options?: SchemaObjectOptions<Schema>
|
||||
) {
|
||||
this._default = Default(_schema, {} as any) as any;
|
||||
this._value = initial
|
||||
? parse(_schema, cloneDeep(initial as any), {
|
||||
forceParse: this.isForceParse(),
|
||||
skipMark: this.isForceParse()
|
||||
})
|
||||
: this._default;
|
||||
this._config = Object.freeze(this._value);
|
||||
}
|
||||
|
||||
protected isForceParse(): boolean {
|
||||
return this.options?.forceParse ?? true;
|
||||
}
|
||||
|
||||
default(): Static<Schema> {
|
||||
return this._default;
|
||||
}
|
||||
|
||||
get(options?: { stripMark?: boolean }): Static<Schema> {
|
||||
if (options?.stripMark) {
|
||||
return stripMark(this._config);
|
||||
}
|
||||
|
||||
return this._config;
|
||||
}
|
||||
|
||||
async set(config: Static<Schema>, noEmit?: boolean): Promise<Static<Schema>> {
|
||||
const valid = parse(this._schema, cloneDeep(config) as any, {
|
||||
forceParse: true,
|
||||
skipMark: this.isForceParse()
|
||||
});
|
||||
this._value = valid;
|
||||
this._config = Object.freeze(valid);
|
||||
|
||||
if (noEmit !== true) {
|
||||
await this.options?.onUpdate?.(this._config);
|
||||
}
|
||||
|
||||
return this._config;
|
||||
}
|
||||
|
||||
bypass() {
|
||||
this._restriction_bypass = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
throwIfRestricted(object: object): void;
|
||||
throwIfRestricted(path: string): void;
|
||||
throwIfRestricted(pathOrObject: string | object): void {
|
||||
// only bypass once
|
||||
if (this._restriction_bypass) {
|
||||
this._restriction_bypass = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = this.options?.restrictPaths ?? [];
|
||||
if (Array.isArray(paths) && paths.length > 0) {
|
||||
for (const path of paths) {
|
||||
const restricted =
|
||||
typeof pathOrObject === "string"
|
||||
? pathOrObject.startsWith(path)
|
||||
: has(pathOrObject, path);
|
||||
|
||||
if (restricted) {
|
||||
throw new Error(`Path "${path}" is restricted`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async patch(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
||||
const current = cloneDeep(this._config);
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
|
||||
|
||||
this.throwIfRestricted(partial);
|
||||
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
|
||||
|
||||
// overwrite arrays and primitives, only deep merge objects
|
||||
// @ts-ignore
|
||||
const config = mergeWith(current, partial, (objValue, srcValue) => {
|
||||
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
|
||||
return srcValue;
|
||||
}
|
||||
});
|
||||
|
||||
//console.log("overwritePaths", this.options?.overwritePaths);
|
||||
if (this.options?.overwritePaths) {
|
||||
const keys = getFullPathKeys(value).map((k) => path + "." + k);
|
||||
const overwritePaths = keys.filter((k) => {
|
||||
return this.options?.overwritePaths?.some((p) => {
|
||||
if (typeof p === "string") {
|
||||
return k === p;
|
||||
} else {
|
||||
return p.test(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
//console.log("overwritePaths", keys, overwritePaths);
|
||||
|
||||
if (overwritePaths.length > 0) {
|
||||
// filter out less specific paths (but only if more than 1)
|
||||
const specific =
|
||||
overwritePaths.length > 1
|
||||
? overwritePaths.filter((k) =>
|
||||
overwritePaths.some((k2) => {
|
||||
console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k));
|
||||
return k2 !== k && k2.startsWith(k);
|
||||
})
|
||||
)
|
||||
: overwritePaths;
|
||||
//console.log("specific", specific);
|
||||
|
||||
for (const p of specific) {
|
||||
set(config, p, get(partial, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//console.log("patch", { path, value, partial, config, current });
|
||||
|
||||
const newConfig = await this.set(config);
|
||||
return [partial, newConfig];
|
||||
}
|
||||
|
||||
async overwrite(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
||||
const current = cloneDeep(this._config);
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
|
||||
|
||||
this.throwIfRestricted(partial);
|
||||
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
|
||||
|
||||
// overwrite arrays and primitives, only deep merge objects
|
||||
// @ts-ignore
|
||||
const config = set(current, path, value);
|
||||
|
||||
//console.log("overwrite", { path, value, partial, config, current });
|
||||
|
||||
const newConfig = await this.set(config);
|
||||
return [partial, newConfig];
|
||||
}
|
||||
|
||||
has(path: string): boolean {
|
||||
const p = path.split(".");
|
||||
if (p.length > 1) {
|
||||
const parent = p.slice(0, -1).join(".");
|
||||
if (!has(this._config, parent)) {
|
||||
console.log("parent", parent, JSON.stringify(this._config, null, 2));
|
||||
throw new Error(`Parent path "${parent}" does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
return has(this._config, path);
|
||||
}
|
||||
|
||||
async remove(path: string): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
||||
this.throwIfRestricted(path);
|
||||
|
||||
if (!this.has(path)) {
|
||||
throw new Error(`Path "${path}" does not exist`);
|
||||
}
|
||||
|
||||
const current = cloneDeep(this._config);
|
||||
const removed = get(current, path) as Partial<Static<Schema>>;
|
||||
const config = omit(current, path);
|
||||
const newConfig = await this.set(config);
|
||||
return [removed, newConfig];
|
||||
}
|
||||
}
|
||||
96
app/src/core/object/query/object-query.ts
Normal file
96
app/src/core/object/query/object-query.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { type FilterQuery, type Primitive, exp, isPrimitive, makeValidator } from "./query";
|
||||
|
||||
const expressions = [
|
||||
exp(
|
||||
"$eq",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(e, a) => e === a
|
||||
),
|
||||
exp(
|
||||
"$ne",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(e, a) => e !== a
|
||||
),
|
||||
exp(
|
||||
"$like",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(e, a) => {
|
||||
switch (typeof a) {
|
||||
case "string":
|
||||
return (a as string).includes(e as string);
|
||||
case "number":
|
||||
return (a as number) === Number(e);
|
||||
case "boolean":
|
||||
return (a as boolean) === Boolean(e);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
),
|
||||
exp(
|
||||
"$regex",
|
||||
(v: RegExp | string) => (v instanceof RegExp ? true : typeof v === "string"),
|
||||
(e: any, a: any) => {
|
||||
if (e instanceof RegExp) {
|
||||
return e.test(a);
|
||||
}
|
||||
if (typeof e === "string") {
|
||||
const regex = new RegExp(e);
|
||||
return regex.test(a);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
),
|
||||
exp(
|
||||
"$isnull",
|
||||
(v: boolean | 1 | 0) => true,
|
||||
(e, a) => (e ? a === null : a !== null)
|
||||
),
|
||||
exp(
|
||||
"$notnull",
|
||||
(v: boolean | 1 | 0) => true,
|
||||
(e, a) => (e ? a !== null : a === null)
|
||||
),
|
||||
exp(
|
||||
"$in",
|
||||
(v: (string | number)[]) => Array.isArray(v),
|
||||
(e: any, a: any) => e.includes(a)
|
||||
),
|
||||
exp(
|
||||
"$notin",
|
||||
(v: (string | number)[]) => Array.isArray(v),
|
||||
(e: any, a: any) => !e.includes(a)
|
||||
),
|
||||
exp(
|
||||
"$gt",
|
||||
(v: number) => typeof v === "number",
|
||||
(e: any, a: any) => a > e
|
||||
),
|
||||
exp(
|
||||
"$gte",
|
||||
(v: number) => typeof v === "number",
|
||||
(e: any, a: any) => a >= e
|
||||
),
|
||||
exp(
|
||||
"$lt",
|
||||
(v: number) => typeof v === "number",
|
||||
(e: any, a: any) => a < e
|
||||
),
|
||||
exp(
|
||||
"$lte",
|
||||
(v: number) => typeof v === "number",
|
||||
(e: any, a: any) => a <= e
|
||||
),
|
||||
exp(
|
||||
"$between",
|
||||
(v: [number, number]) =>
|
||||
Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === "number"),
|
||||
(e: any, a: any) => e[0] <= a && a <= e[1]
|
||||
)
|
||||
];
|
||||
|
||||
export type ObjectQuery = FilterQuery<typeof expressions>;
|
||||
const validator = makeValidator(expressions);
|
||||
export const convert = (query: ObjectQuery) => validator.convert(query);
|
||||
export const validate = (query: ObjectQuery, object: Record<string, any>) =>
|
||||
validator.validate(query, { object, convert: true });
|
||||
209
app/src/core/object/query/query.ts
Normal file
209
app/src/core/object/query/query.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
export type Primitive = string | number | boolean;
|
||||
export function isPrimitive(value: any): value is Primitive {
|
||||
return ["string", "number", "boolean"].includes(typeof value);
|
||||
}
|
||||
export type BooleanLike = boolean | 0 | 1;
|
||||
export function isBooleanLike(value: any): value is boolean {
|
||||
return [true, false, 0, 1].includes(value);
|
||||
}
|
||||
|
||||
export class Expression<Key, Expect = unknown, CTX = any> {
|
||||
expect!: Expect;
|
||||
|
||||
constructor(
|
||||
public key: Key,
|
||||
public valid: (v: Expect) => boolean,
|
||||
public validate: (e: any, a: any, ctx: CTX) => any
|
||||
) {}
|
||||
}
|
||||
export type TExpression<Key, Expect = unknown, CTX = any> = Expression<Key, Expect, CTX>;
|
||||
|
||||
export function exp<const Key, const Expect, CTX = any>(
|
||||
key: Key,
|
||||
valid: (v: Expect) => boolean,
|
||||
validate: (e: Expect, a: unknown, ctx: CTX) => any
|
||||
): Expression<Key, Expect, CTX> {
|
||||
return new Expression(key, valid, validate);
|
||||
}
|
||||
|
||||
type Expressions = Expression<any, any>[];
|
||||
type ExpressionMap<Exps extends Expressions> = {
|
||||
[K in Exps[number]["key"]]: Extract<Exps[number], { key: K }> extends Expression<K, infer E>
|
||||
? E
|
||||
: never;
|
||||
};
|
||||
type ExpressionCondition<Exps extends Expressions> = {
|
||||
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
|
||||
}[keyof ExpressionMap<Exps>];
|
||||
|
||||
function getExpression<Exps extends Expressions>(
|
||||
expressions: Exps,
|
||||
key: string
|
||||
): Expression<any, any> {
|
||||
const exp = expressions.find((e) => e.key === key);
|
||||
if (!exp) throw new Error(`Expression does not exist: "${key}"`);
|
||||
return exp as any;
|
||||
}
|
||||
|
||||
type LiteralExpressionCondition<Exps extends Expressions> = {
|
||||
[key: string]: Primitive | ExpressionCondition<Exps>;
|
||||
};
|
||||
|
||||
const OperandOr = "$or";
|
||||
type OperandCondition<Exps extends Expressions> = {
|
||||
[OperandOr]?: LiteralExpressionCondition<Exps> | ExpressionCondition<Exps>;
|
||||
};
|
||||
|
||||
export type FilterQuery<Exps extends Expressions> =
|
||||
| LiteralExpressionCondition<Exps>
|
||||
| OperandCondition<Exps>;
|
||||
|
||||
function _convert<Exps extends Expressions>(
|
||||
$query: FilterQuery<Exps>,
|
||||
expressions: Exps,
|
||||
path: string[] = []
|
||||
): FilterQuery<Exps> {
|
||||
//console.log("-----------------");
|
||||
const ExpressionConditionKeys = expressions.map((e) => e.key);
|
||||
const keys = Object.keys($query);
|
||||
const operands = [OperandOr] as const;
|
||||
const newQuery: FilterQuery<Exps> = {};
|
||||
|
||||
if (keys.some((k) => k.startsWith("$") && !operands.includes(k as any))) {
|
||||
throw new Error(`Invalid key '${keys}'. Keys must not start with '$'.`);
|
||||
}
|
||||
|
||||
if (path.length > 0 && keys.some((k) => operands.includes(k as any))) {
|
||||
throw new Error(`Operand ${OperandOr} can only appear at the top level.`);
|
||||
}
|
||||
|
||||
function validate(key: string, value: any, path: string[] = []) {
|
||||
const exp = getExpression(expressions, key as any);
|
||||
if (exp.valid(value) === false) {
|
||||
throw new Error(`Invalid value at "${[...path, key].join(".")}": ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries($query)) {
|
||||
// if $or, convert each value
|
||||
if (key === "$or") {
|
||||
newQuery.$or = _convert(value, expressions, [...path, key]);
|
||||
|
||||
// if primitive, assume $eq
|
||||
} else if (isPrimitive(value)) {
|
||||
validate("$eq", value, path);
|
||||
newQuery[key] = { $eq: value };
|
||||
|
||||
// if object, check for expressions
|
||||
} else if (typeof value === "object") {
|
||||
// when object is given, check if all keys are expressions
|
||||
const invalid = Object.keys(value).filter(
|
||||
(f) => !ExpressionConditionKeys.includes(f as any)
|
||||
);
|
||||
if (invalid.length === 0) {
|
||||
newQuery[key] = {};
|
||||
// validate each expression
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
validate(k, v, [...path, key]);
|
||||
newQuery[key][k] = v;
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expressions.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newQuery;
|
||||
}
|
||||
|
||||
type ValidationResults = { $and: any[]; $or: any[]; keys: Set<string> };
|
||||
type BuildOptions = {
|
||||
object?: any;
|
||||
exp_ctx?: any;
|
||||
convert?: boolean;
|
||||
value_is_kv?: boolean;
|
||||
};
|
||||
function _build<Exps extends Expressions>(
|
||||
_query: FilterQuery<Exps>,
|
||||
expressions: Exps,
|
||||
options: BuildOptions
|
||||
): ValidationResults {
|
||||
const $query = options.convert ? _convert<Exps>(_query, expressions) : _query;
|
||||
|
||||
//console.log("-----------------", { $query });
|
||||
//const keys = Object.keys($query);
|
||||
const result: ValidationResults = {
|
||||
$and: [],
|
||||
$or: [],
|
||||
keys: new Set<string>()
|
||||
};
|
||||
|
||||
const { $or, ...$and } = $query;
|
||||
|
||||
function __validate($op: string, expected: any, actual: any, path: string[] = []) {
|
||||
const exp = getExpression(expressions, $op as any);
|
||||
if (!exp) {
|
||||
throw new Error(`Expression does not exist: "${$op}"`);
|
||||
}
|
||||
if (!exp.valid(expected)) {
|
||||
throw new Error(`Invalid expected value at "${[...path, $op].join(".")}": ${expected}`);
|
||||
}
|
||||
//console.log("found exp", { key: exp.key, expected, actual });
|
||||
return exp.validate(expected, actual, options.exp_ctx);
|
||||
}
|
||||
|
||||
// check $and
|
||||
//console.log("$and entries", Object.entries($and));
|
||||
for (const [key, value] of Object.entries($and)) {
|
||||
//console.log("$op/$v", Object.entries(value));
|
||||
for (const [$op, $v] of Object.entries(value)) {
|
||||
const objValue = options.value_is_kv ? key : options.object[key];
|
||||
//console.log("--check $and", { key, value, objValue, v_i_kv: options.value_is_kv });
|
||||
//console.log("validate", { $op, $v, objValue, key });
|
||||
result.$and.push(__validate($op, $v, objValue, [key]));
|
||||
result.keys.add(key);
|
||||
}
|
||||
//console.log("-", { key, value });
|
||||
}
|
||||
|
||||
// check $or
|
||||
for (const [key, value] of Object.entries($or ?? {})) {
|
||||
const objValue = options.value_is_kv ? key : options.object[key];
|
||||
|
||||
for (const [$op, $v] of Object.entries(value)) {
|
||||
//console.log("validate", { $op, $v, objValue });
|
||||
result.$or.push(__validate($op, $v, objValue, [key]));
|
||||
result.keys.add(key);
|
||||
}
|
||||
//console.log("-", { key, value });
|
||||
}
|
||||
|
||||
//console.log("matches", matches);
|
||||
return result;
|
||||
}
|
||||
|
||||
function _validate(results: ValidationResults): boolean {
|
||||
const matches: { $and?: boolean; $or?: boolean } = {
|
||||
$and: undefined,
|
||||
$or: undefined
|
||||
};
|
||||
|
||||
matches.$and = results.$and.every((r) => Boolean(r));
|
||||
matches.$or = results.$or.some((r) => Boolean(r));
|
||||
|
||||
return !!matches.$and || !!matches.$or;
|
||||
}
|
||||
|
||||
export function makeValidator<Exps extends Expressions>(expressions: Exps) {
|
||||
return {
|
||||
convert: (query: FilterQuery<Exps>) => _convert(query, expressions),
|
||||
build: (query: FilterQuery<Exps>, options: BuildOptions) =>
|
||||
_build(query, expressions, options),
|
||||
validate: (query: FilterQuery<Exps>, options: BuildOptions) => {
|
||||
const fns = _build(query, expressions, options);
|
||||
return _validate(fns);
|
||||
}
|
||||
};
|
||||
}
|
||||
30
app/src/core/registry/Registry.ts
Normal file
30
app/src/core/registry/Registry.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type Constructor<T> = new (...args: any[]) => T;
|
||||
export class Registry<Item, Items extends Record<string, object> = Record<string, object>> {
|
||||
private is_set: boolean = false;
|
||||
private items: Items = {} as Items;
|
||||
|
||||
set<Actual extends Record<string, object>>(items: Actual) {
|
||||
if (this.is_set) {
|
||||
throw new Error("Registry is already set");
|
||||
}
|
||||
// @ts-ignore
|
||||
this.items = items;
|
||||
this.is_set = true;
|
||||
|
||||
return this as unknown as Registry<Item, Actual>;
|
||||
}
|
||||
|
||||
add(name: string, item: Item) {
|
||||
// @ts-ignore
|
||||
this.items[name] = item;
|
||||
return this;
|
||||
}
|
||||
|
||||
get<Name extends keyof Items>(name: Name): Items[Name] {
|
||||
return this.items[name];
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.items;
|
||||
}
|
||||
}
|
||||
11
app/src/core/security/Permission.ts
Normal file
11
app/src/core/security/Permission.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class Permission<Name extends string = string> {
|
||||
constructor(public name: Name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name
|
||||
};
|
||||
}
|
||||
}
|
||||
29
app/src/core/server/ContextHelper.ts
Normal file
29
app/src/core/server/ContextHelper.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Context } from "hono";
|
||||
|
||||
export class ContextHelper {
|
||||
constructor(protected c: Context) {}
|
||||
|
||||
contentTypeMime(): string {
|
||||
const contentType = this.c.res.headers.get("Content-Type");
|
||||
if (contentType) {
|
||||
return String(contentType.split(";")[0]);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
isHtml(): boolean {
|
||||
return this.contentTypeMime() === "text/html";
|
||||
}
|
||||
|
||||
url(): URL {
|
||||
return new URL(this.c.req.url);
|
||||
}
|
||||
|
||||
headersObject() {
|
||||
const headers = {};
|
||||
for (const [k, v] of this.c.res.headers.entries()) {
|
||||
headers[k] = v;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
155
app/src/core/server/Controller.ts
Normal file
155
app/src/core/server/Controller.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Hono, type MiddlewareHandler, type ValidationTargets } from "hono";
|
||||
import type { H } from "hono/types";
|
||||
import { safelyParseObjectValues } from "../utils";
|
||||
import type { Endpoint, Middleware } from "./Endpoint";
|
||||
import { zValidator } from "./lib/zValidator";
|
||||
|
||||
type RouteProxy<Endpoints> = {
|
||||
[K in keyof Endpoints]: Endpoints[K];
|
||||
};
|
||||
|
||||
export interface ClassController {
|
||||
getController: () => Hono<any, any, any>;
|
||||
getMiddleware?: MiddlewareHandler<any, any, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export class Controller<
|
||||
Endpoints extends Record<string, Endpoint> = Record<string, Endpoint>,
|
||||
Middlewares extends Record<string, Middleware> = Record<string, Middleware>
|
||||
> {
|
||||
protected endpoints: Endpoints = {} as Endpoints;
|
||||
protected middlewares: Middlewares = {} as Middlewares;
|
||||
|
||||
public prefix: string = "/";
|
||||
public routes: RouteProxy<Endpoints>;
|
||||
|
||||
constructor(
|
||||
prefix: string = "/",
|
||||
endpoints: Endpoints = {} as Endpoints,
|
||||
middlewares: Middlewares = {} as Middlewares
|
||||
) {
|
||||
this.prefix = prefix;
|
||||
this.endpoints = endpoints;
|
||||
this.middlewares = middlewares;
|
||||
|
||||
this.routes = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, name: string) => {
|
||||
return this.endpoints[name];
|
||||
}
|
||||
}
|
||||
) as RouteProxy<Endpoints>;
|
||||
}
|
||||
|
||||
add<Name extends string, E extends Endpoint>(
|
||||
this: Controller<Endpoints>,
|
||||
name: Name,
|
||||
endpoint: E
|
||||
): Controller<Endpoints & Record<Name, E>> {
|
||||
const newEndpoints = {
|
||||
...this.endpoints,
|
||||
[name]: endpoint
|
||||
} as Endpoints & Record<Name, E>;
|
||||
const newController: Controller<Endpoints & Record<Name, E>> = new Controller<
|
||||
Endpoints & Record<Name, E>
|
||||
>();
|
||||
newController.endpoints = newEndpoints;
|
||||
newController.middlewares = this.middlewares;
|
||||
return newController;
|
||||
}
|
||||
|
||||
get<Name extends keyof Endpoints>(name: Name): Endpoints[Name] {
|
||||
return this.endpoints[name];
|
||||
}
|
||||
|
||||
honoify(_hono: Hono = new Hono()) {
|
||||
const hono = _hono.basePath(this.prefix);
|
||||
|
||||
// apply middlewares
|
||||
for (const m_name in this.middlewares) {
|
||||
const middleware = this.middlewares[m_name];
|
||||
|
||||
if (typeof middleware === "function") {
|
||||
//if (isDebug()) console.log("+++ appyling middleware", m_name, middleware);
|
||||
hono.use(middleware);
|
||||
}
|
||||
}
|
||||
|
||||
// apply endpoints
|
||||
for (const name in this.endpoints) {
|
||||
const endpoint = this.endpoints[name];
|
||||
if (!endpoint) continue;
|
||||
|
||||
const handlers: H[] = [];
|
||||
|
||||
const supportedValidations: Array<keyof ValidationTargets> = ["param", "query", "json"];
|
||||
|
||||
// if validations are present, add them to the handlers
|
||||
for (const validation of supportedValidations) {
|
||||
if (endpoint.validation[validation]) {
|
||||
handlers.push(async (c, next) => {
|
||||
// @todo: potentially add "strict" to all schemas?
|
||||
const res = await zValidator(
|
||||
validation,
|
||||
endpoint.validation[validation] as any,
|
||||
(target, value, c) => {
|
||||
if (["query", "param"].includes(target)) {
|
||||
return safelyParseObjectValues(value);
|
||||
}
|
||||
//console.log("preprocess", target, value, c.req.raw.url);
|
||||
return value;
|
||||
}
|
||||
)(c, next);
|
||||
|
||||
if (res instanceof Response && res.status === 400) {
|
||||
const error = await res.json();
|
||||
return c.json(
|
||||
{
|
||||
error: "Validation error",
|
||||
target: validation,
|
||||
message: error
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// add actual handler
|
||||
handlers.push(endpoint.toHandler());
|
||||
|
||||
const method = endpoint.method.toLowerCase() as
|
||||
| "get"
|
||||
| "post"
|
||||
| "put"
|
||||
| "delete"
|
||||
| "patch";
|
||||
|
||||
//if (isDebug()) console.log("--- adding", method, endpoint.path);
|
||||
hono[method](endpoint.path, ...handlers);
|
||||
}
|
||||
|
||||
return hono;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const endpoints: any = {};
|
||||
for (const name in this.endpoints) {
|
||||
const endpoint = this.endpoints[name];
|
||||
if (!endpoint) continue;
|
||||
|
||||
endpoints[name] = {
|
||||
method: endpoint.method,
|
||||
path: (this.prefix + endpoint.path).replace("//", "/")
|
||||
};
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
147
app/src/core/server/Endpoint.ts
Normal file
147
app/src/core/server/Endpoint.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Context, MiddlewareHandler, Next, ValidationTargets } from "hono";
|
||||
import type { Handler } from "hono/types";
|
||||
import { encodeSearch, replaceUrlParam } from "../utils";
|
||||
import type { Prettify } from "../utils";
|
||||
|
||||
type ZodSchema = { [key: string]: any };
|
||||
|
||||
type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
type Validation<P, Q, B> = {
|
||||
[K in keyof ValidationTargets]?: any;
|
||||
} & {
|
||||
param?: P extends ZodSchema ? P : undefined;
|
||||
query?: Q extends ZodSchema ? Q : undefined;
|
||||
json?: B extends ZodSchema ? B : undefined;
|
||||
};
|
||||
|
||||
type ValidationInput<P, Q, B> = {
|
||||
param?: P extends ZodSchema ? P["_input"] : undefined;
|
||||
query?: Q extends ZodSchema ? Q["_input"] : undefined;
|
||||
json?: B extends ZodSchema ? B["_input"] : undefined;
|
||||
};
|
||||
|
||||
type HonoEnv = any;
|
||||
|
||||
export type Middleware = MiddlewareHandler<any, any, any>;
|
||||
|
||||
type HandlerFunction<P extends string, R> = (c: Context<HonoEnv, P, any>, next: Next) => R;
|
||||
export type RequestResponse<R> = {
|
||||
status: number;
|
||||
ok: boolean;
|
||||
response: Awaited<R>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export class Endpoint<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
> {
|
||||
constructor(
|
||||
readonly method: Method,
|
||||
readonly path: Path,
|
||||
readonly handler: HandlerFunction<Path, R>,
|
||||
readonly validation: Validation<P, Q, B> = {}
|
||||
) {}
|
||||
|
||||
// @todo: typing is not ideal
|
||||
async $request(
|
||||
args?: ValidationInput<P, Q, B>,
|
||||
baseUrl: string = "http://localhost:28623"
|
||||
): Promise<Prettify<RequestResponse<R>>> {
|
||||
let path = this.path as string;
|
||||
if (args?.param) {
|
||||
path = replaceUrlParam(path, args.param);
|
||||
}
|
||||
|
||||
if (args?.query) {
|
||||
path += "?" + encodeSearch(args.query);
|
||||
}
|
||||
|
||||
const url = [baseUrl, path].join("").replace(/\/$/, "");
|
||||
const options: RequestInit = {
|
||||
method: this.method,
|
||||
headers: {} as any
|
||||
};
|
||||
|
||||
if (!["GET", "HEAD"].includes(this.method)) {
|
||||
if (args?.json) {
|
||||
options.body = JSON.stringify(args.json);
|
||||
options.headers!["Content-Type"] = "application/json";
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, options);
|
||||
return {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
response: (await res.json()) as any
|
||||
};
|
||||
}
|
||||
|
||||
toHandler(): Handler {
|
||||
return async (c, next) => {
|
||||
const res = await this.handler(c, next);
|
||||
//console.log("toHandler:isResponse", res instanceof Response);
|
||||
//return res;
|
||||
if (res instanceof Response) {
|
||||
return res;
|
||||
}
|
||||
return c.json(res as any) as unknown as Handler;
|
||||
};
|
||||
}
|
||||
|
||||
static get<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
|
||||
return new Endpoint<Path, P, Q, B, R>("GET", path, handler, validation);
|
||||
}
|
||||
|
||||
static post<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
|
||||
return new Endpoint<Path, P, Q, B, R>("POST", path, handler, validation);
|
||||
}
|
||||
|
||||
static patch<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
|
||||
return new Endpoint<Path, P, Q, B, R>("PATCH", path, handler, validation);
|
||||
}
|
||||
|
||||
static put<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
|
||||
return new Endpoint<Path, P, Q, B, R>("PUT", path, handler, validation);
|
||||
}
|
||||
|
||||
static delete<
|
||||
Path extends string = any,
|
||||
P extends ZodSchema = any,
|
||||
Q extends ZodSchema = any,
|
||||
B extends ZodSchema = any,
|
||||
R = any
|
||||
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
|
||||
return new Endpoint<Path, P, Q, B, R>("DELETE", path, handler, validation);
|
||||
}
|
||||
}
|
||||
37
app/src/core/server/lib/tbValidator.ts
Normal file
37
app/src/core/server/lib/tbValidator.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { StaticDecode, TSchema } from "@sinclair/typebox";
|
||||
import { Value, type ValueError } from "@sinclair/typebox/value";
|
||||
import type { Context, Env, MiddlewareHandler, ValidationTargets } from "hono";
|
||||
import { validator } from "hono/validator";
|
||||
|
||||
type Hook<T, E extends Env, P extends string> = (
|
||||
result: { success: true; data: T } | { success: false; errors: ValueError[] },
|
||||
c: Context<E, P>
|
||||
) => Response | Promise<Response> | void;
|
||||
|
||||
export function tbValidator<
|
||||
T extends TSchema,
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
V extends { in: { [K in Target]: StaticDecode<T> }; out: { [K in Target]: StaticDecode<T> } }
|
||||
>(target: Target, schema: T, hook?: Hook<StaticDecode<T>, E, P>): MiddlewareHandler<E, P, V> {
|
||||
// Compile the provided schema once rather than per validation. This could be optimized further using a shared schema
|
||||
// compilation pool similar to the Fastify implementation.
|
||||
|
||||
// @ts-expect-error not typed well
|
||||
return validator(target, (data, c) => {
|
||||
if (Value.Check(schema, data)) {
|
||||
// always decode
|
||||
const decoded = Value.Decode(schema, data);
|
||||
|
||||
if (hook) {
|
||||
const hookResult = hook({ success: true, data: decoded }, c);
|
||||
if (hookResult instanceof Response || hookResult instanceof Promise) {
|
||||
return hookResult;
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
return c.json({ success: false, errors: [...Value.Errors(schema, data)] }, 400);
|
||||
});
|
||||
}
|
||||
75
app/src/core/server/lib/zValidator.ts
Normal file
75
app/src/core/server/lib/zValidator.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
Context,
|
||||
Env,
|
||||
Input,
|
||||
MiddlewareHandler,
|
||||
TypedResponse,
|
||||
ValidationTargets,
|
||||
} from "hono";
|
||||
import { validator } from "hono/validator";
|
||||
import type { ZodError, ZodSchema, z } from "zod";
|
||||
|
||||
export type Hook<T, E extends Env, P extends string, O = {}> = (
|
||||
result: { success: true; data: T } | { success: false; error: ZodError; data: T },
|
||||
c: Context<E, P>,
|
||||
) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
|
||||
|
||||
type HasUndefined<T> = undefined extends T ? true : false;
|
||||
|
||||
export const zValidator = <
|
||||
T extends ZodSchema,
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
In = z.input<T>,
|
||||
Out = z.output<T>,
|
||||
I extends Input = {
|
||||
in: HasUndefined<In> extends true
|
||||
? {
|
||||
[K in Target]?: K extends "json"
|
||||
? In
|
||||
: HasUndefined<keyof ValidationTargets[K]> extends true
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] };
|
||||
}
|
||||
: {
|
||||
[K in Target]: K extends "json"
|
||||
? In
|
||||
: HasUndefined<keyof ValidationTargets[K]> extends true
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] };
|
||||
};
|
||||
out: { [K in Target]: Out };
|
||||
},
|
||||
V extends I = I,
|
||||
>(
|
||||
target: Target,
|
||||
schema: T,
|
||||
preprocess?: (target: string, value: In, c: Context<E, P>) => V, // <-- added
|
||||
hook?: Hook<z.infer<T>, E, P>,
|
||||
): MiddlewareHandler<E, P, V> =>
|
||||
// @ts-expect-error not typed well
|
||||
validator(target, async (value, c) => {
|
||||
// added: preprocess value first if given
|
||||
const _value = preprocess ? preprocess(target, value, c) : (value as any);
|
||||
const result = await schema.safeParseAsync(_value);
|
||||
|
||||
if (hook) {
|
||||
const hookResult = await hook({ data: value, ...result }, c);
|
||||
if (hookResult) {
|
||||
if (hookResult instanceof Response) {
|
||||
return hookResult;
|
||||
}
|
||||
|
||||
if ("response" in hookResult) {
|
||||
return hookResult.response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return c.json(result, 400);
|
||||
}
|
||||
|
||||
return result.data as z.infer<T>;
|
||||
});
|
||||
96
app/src/core/template/SimpleRenderer.ts
Normal file
96
app/src/core/template/SimpleRenderer.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Liquid, LiquidError } from "liquidjs";
|
||||
import type { RenderOptions } from "liquidjs/dist/liquid-options";
|
||||
import { BkndError } from "../errors";
|
||||
|
||||
export type TemplateObject = Record<string, string | Record<string, string>>;
|
||||
export type TemplateTypes = string | TemplateObject;
|
||||
|
||||
export type SimpleRendererOptions = RenderOptions & {
|
||||
renderKeys?: boolean;
|
||||
};
|
||||
|
||||
export class SimpleRenderer {
|
||||
private engine = new Liquid();
|
||||
|
||||
constructor(
|
||||
private variables: Record<string, any> = {},
|
||||
private options: SimpleRendererOptions = {}
|
||||
) {}
|
||||
|
||||
another() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
static hasMarkup(template: string | object): boolean {
|
||||
//console.log("has markup?", template);
|
||||
let flat: string = "";
|
||||
|
||||
if (Array.isArray(template) || typeof template === "object") {
|
||||
// only plain arrays and objects
|
||||
if (!["Array", "Object"].includes(template.constructor.name)) return false;
|
||||
|
||||
flat = JSON.stringify(template);
|
||||
} else {
|
||||
flat = String(template);
|
||||
}
|
||||
|
||||
//console.log("** flat", flat);
|
||||
|
||||
const checks = ["{{", "{%", "{#", "{:"];
|
||||
const hasMarkup = checks.some((check) => flat.includes(check));
|
||||
//console.log("--has markup?", hasMarkup);
|
||||
return hasMarkup;
|
||||
}
|
||||
|
||||
async render<Given extends TemplateTypes>(template: Given): Promise<Given> {
|
||||
try {
|
||||
if (typeof template === "string") {
|
||||
return (await this.renderString(template)) as unknown as Given;
|
||||
} else if (Array.isArray(template)) {
|
||||
return (await Promise.all(
|
||||
template.map((item) => this.render(item))
|
||||
)) as unknown as Given;
|
||||
} else if (typeof template === "object") {
|
||||
return (await this.renderObject(template)) as unknown as Given;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof LiquidError) {
|
||||
const details = {
|
||||
name: e.name,
|
||||
token: {
|
||||
kind: e.token.kind,
|
||||
input: e.token.input,
|
||||
begin: e.token.begin,
|
||||
end: e.token.end
|
||||
}
|
||||
};
|
||||
|
||||
throw new BkndError(e.message, details, "liquid");
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw new Error("Invalid template type");
|
||||
}
|
||||
|
||||
async renderString(template: string): Promise<string> {
|
||||
//console.log("*** renderString", template, this.variables);
|
||||
return this.engine.parseAndRender(template, this.variables, this.options);
|
||||
}
|
||||
|
||||
async renderObject(template: TemplateObject): Promise<TemplateObject> {
|
||||
const result: TemplateObject = {};
|
||||
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
let resultKey = key;
|
||||
if (this.options.renderKeys) {
|
||||
resultKey = await this.renderString(key);
|
||||
}
|
||||
|
||||
result[resultKey] = await this.render(value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
4
app/src/core/types.ts
Normal file
4
app/src/core/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Serializable<Class, Json extends object = object> {
|
||||
toJSON(): Json;
|
||||
fromJSON(json: Json): Class;
|
||||
}
|
||||
36
app/src/core/utils/DebugLogger.ts
Normal file
36
app/src/core/utils/DebugLogger.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export class DebugLogger {
|
||||
public _context: string[] = [];
|
||||
_enabled: boolean = true;
|
||||
private readonly id = Math.random().toString(36).substr(2, 9);
|
||||
private last: number = 0;
|
||||
|
||||
constructor(enabled: boolean = true) {
|
||||
this._enabled = enabled;
|
||||
}
|
||||
|
||||
context(context: string) {
|
||||
//console.log("[ settings context ]", context, this._context);
|
||||
this._context.push(context);
|
||||
return this;
|
||||
}
|
||||
|
||||
clear() {
|
||||
//console.log("[ clear context ]", this._context.pop(), this._context);
|
||||
this._context.pop();
|
||||
return this;
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
if (!this._enabled) return this;
|
||||
|
||||
const now = performance.now();
|
||||
const time = Number.parseInt(String(now - this.last));
|
||||
const indents = " ".repeat(this._context.length);
|
||||
const context =
|
||||
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
|
||||
console.log(indents, context, time, ...args);
|
||||
|
||||
this.last = now;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
20
app/src/core/utils/browser.ts
Normal file
20
app/src/core/utils/browser.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type TBrowser = "Opera" | "Edge" | "Chrome" | "Safari" | "Firefox" | "IE" | "unknown";
|
||||
export function getBrowser(): TBrowser {
|
||||
if ((navigator.userAgent.indexOf("Opera") || navigator.userAgent.indexOf("OPR")) !== -1) {
|
||||
return "Opera";
|
||||
} else if (navigator.userAgent.indexOf("Edg") !== -1) {
|
||||
return "Edge";
|
||||
} else if (navigator.userAgent.indexOf("Chrome") !== -1) {
|
||||
return "Chrome";
|
||||
} else if (navigator.userAgent.indexOf("Safari") !== -1) {
|
||||
return "Safari";
|
||||
} else if (navigator.userAgent.indexOf("Firefox") !== -1) {
|
||||
return "Firefox";
|
||||
// @ts-ignore
|
||||
} else if (navigator.userAgent.indexOf("MSIE") !== -1 || !!document.documentMode === true) {
|
||||
//IF IE > 10
|
||||
return "IE";
|
||||
} else {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
29
app/src/core/utils/crypto.ts
Normal file
29
app/src/core/utils/crypto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export const HashAlgorithms = ["SHA-1", "SHA-256", "SHA-384", "SHA-512"] as const;
|
||||
export type HashAlgorithm = (typeof HashAlgorithms)[number];
|
||||
export async function digest(alg: HashAlgorithm, input: string, salt?: string, pepper?: string) {
|
||||
if (!HashAlgorithms.includes(alg)) {
|
||||
throw new Error(`Invalid hash algorithm: ${alg}`);
|
||||
}
|
||||
|
||||
// convert to Uint8Array
|
||||
const data = new TextEncoder().encode((salt ?? "") + input + (pepper ?? ""));
|
||||
|
||||
// hash to alg
|
||||
const hashBuffer = await crypto.subtle.digest(alg, data);
|
||||
|
||||
// convert hash to hex string
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export const hash = {
|
||||
sha256: async (input: string, salt?: string, pepper?: string) =>
|
||||
digest("SHA-256", input, salt, pepper),
|
||||
sha1: async (input: string, salt?: string, pepper?: string) =>
|
||||
digest("SHA-1", input, salt, pepper)
|
||||
};
|
||||
|
||||
export async function checksum(s: any) {
|
||||
const o = typeof s === "string" ? s : JSON.stringify(s);
|
||||
return await digest("SHA-1", o);
|
||||
}
|
||||
14
app/src/core/utils/dates.ts
Normal file
14
app/src/core/utils/dates.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import dayjs from "dayjs";
|
||||
import weekOfYear from "dayjs/plugin/weekOfYear.js";
|
||||
|
||||
declare module "dayjs" {
|
||||
interface Dayjs {
|
||||
week(): number;
|
||||
|
||||
week(value: number): dayjs.Dayjs;
|
||||
}
|
||||
}
|
||||
|
||||
dayjs.extend(weekOfYear);
|
||||
|
||||
export { dayjs };
|
||||
13
app/src/core/utils/index.ts
Normal file
13
app/src/core/utils/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export * from "./browser";
|
||||
export * from "./objects";
|
||||
export * from "./strings";
|
||||
export * from "./perf";
|
||||
export * from "./reqres";
|
||||
export * from "./xml";
|
||||
export type { Prettify, PrettifyRec } from "./types";
|
||||
export * from "./typebox";
|
||||
export * from "./dates";
|
||||
export * from "./crypto";
|
||||
export * from "./uuid";
|
||||
export { FromSchema } from "./typebox/from-schema";
|
||||
export * from "./test";
|
||||
198
app/src/core/utils/objects.ts
Normal file
198
app/src/core/utils/objects.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { pascalToKebab } from "./strings";
|
||||
|
||||
export function _jsonp(obj: any, space = 2): string {
|
||||
return JSON.stringify(obj, null, space);
|
||||
}
|
||||
|
||||
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
acc[key] = JSON.parse(value);
|
||||
} catch (error) {
|
||||
// @ts-ignore
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as T);
|
||||
}
|
||||
|
||||
export function keepChanged<T extends object>(origin: T, updated: T): Partial<T> {
|
||||
return Object.keys(updated).reduce(
|
||||
(acc, key) => {
|
||||
if (updated[key] !== origin[key]) {
|
||||
acc[key] = updated[key];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Partial<T>
|
||||
);
|
||||
}
|
||||
|
||||
export function objectKeysPascalToKebab(obj: any, ignoreKeys: string[] = []): any {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => objectKeysPascalToKebab(item, ignoreKeys));
|
||||
}
|
||||
|
||||
return Object.keys(obj).reduce(
|
||||
(acc, key) => {
|
||||
const kebabKey = ignoreKeys.includes(key) ? key : pascalToKebab(key);
|
||||
acc[kebabKey] = objectKeysPascalToKebab(obj[key], ignoreKeys);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
}
|
||||
|
||||
export function filterKeys<Object extends { [key: string]: any }>(
|
||||
obj: Object,
|
||||
keysToFilter: string[]
|
||||
): Object {
|
||||
const result = {} as Object;
|
||||
|
||||
for (const key in obj) {
|
||||
const shouldFilter = keysToFilter.some((filterKey) => key.includes(filterKey));
|
||||
if (!shouldFilter) {
|
||||
if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||
result[key] = filterKeys(obj[key], keysToFilter);
|
||||
} else {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function transformObject<T extends Record<string, any>, U>(
|
||||
object: T,
|
||||
transform: (value: T[keyof T], key: keyof T) => U | undefined
|
||||
): { [K in keyof T]: U } {
|
||||
return Object.entries(object).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const t = transform(value, key as keyof T);
|
||||
if (typeof t !== "undefined") {
|
||||
acc[key as keyof T] = t;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as { [K in keyof T]: U }
|
||||
);
|
||||
}
|
||||
export const objectTransform = transformObject;
|
||||
|
||||
export function objectEach<T extends Record<string, any>, U>(
|
||||
object: T,
|
||||
each: (value: T[keyof T], key: keyof T) => U
|
||||
): void {
|
||||
Object.entries(object).forEach(
|
||||
([key, value]) => {
|
||||
each(value, key);
|
||||
},
|
||||
{} as { [K in keyof T]: U }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple object check.
|
||||
* @param item
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isObject(item) {
|
||||
return item && typeof item === "object" && !Array.isArray(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects.
|
||||
* @param target
|
||||
* @param ...sources
|
||||
*/
|
||||
export function mergeDeep(target, ...sources) {
|
||||
if (!sources.length) return target;
|
||||
const source = sources.shift();
|
||||
|
||||
if (isObject(target) && isObject(source)) {
|
||||
for (const key in source) {
|
||||
if (isObject(source[key])) {
|
||||
if (!target[key]) Object.assign(target, { [key]: {} });
|
||||
mergeDeep(target[key], source[key]);
|
||||
} else {
|
||||
Object.assign(target, { [key]: source[key] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeDeep(target, ...sources);
|
||||
}
|
||||
|
||||
export function getFullPathKeys(obj: any, parentPath: string = ""): string[] {
|
||||
let keys: string[] = [];
|
||||
|
||||
for (const key in obj) {
|
||||
const fullPath = parentPath ? `${parentPath}.${key}` : key;
|
||||
keys.push(fullPath);
|
||||
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
keys = keys.concat(getFullPathKeys(obj[key], fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function flattenObject(obj: any, parentKey = "", result: any = {}): any {
|
||||
for (const key in obj) {
|
||||
if (key in obj) {
|
||||
const newKey = parentKey ? `${parentKey}.${key}` : key;
|
||||
if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||
flattenObject(obj[key], newKey, result);
|
||||
} else if (Array.isArray(obj[key])) {
|
||||
obj[key].forEach((item, index) => {
|
||||
const arrayKey = `${newKey}.${index}`;
|
||||
if (typeof item === "object" && item !== null) {
|
||||
flattenObject(item, arrayKey, result);
|
||||
} else {
|
||||
result[arrayKey] = item;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
result[newKey] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function objectDepth(object: object): number {
|
||||
let level = 1;
|
||||
for (const key in object) {
|
||||
if (typeof object[key] === "object") {
|
||||
const depth = objectDepth(object[key]) + 1;
|
||||
level = Math.max(depth, level);
|
||||
}
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj): Obj {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
if (value && Array.isArray(value) && value.some((v) => typeof v === "object")) {
|
||||
const nested = value.map(objectCleanEmpty);
|
||||
if (nested.length > 0) {
|
||||
acc[key] = nested;
|
||||
}
|
||||
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const nested = objectCleanEmpty(value);
|
||||
if (Object.keys(nested).length > 0) {
|
||||
acc[key] = nested;
|
||||
}
|
||||
} else if (value !== "" && value !== null && value !== undefined) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as any);
|
||||
}
|
||||
60
app/src/core/utils/perf.ts
Normal file
60
app/src/core/utils/perf.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export class Perf {
|
||||
private marks: { mark: string; time: number }[] = [];
|
||||
private startTime: number;
|
||||
private endTime: number | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.startTime = performance.now();
|
||||
}
|
||||
|
||||
static start(): Perf {
|
||||
return new Perf();
|
||||
}
|
||||
|
||||
mark(markName: string): void {
|
||||
if (this.endTime !== null) {
|
||||
throw new Error("Cannot add marks after perf measurement has been closed.");
|
||||
}
|
||||
|
||||
const currentTime = performance.now();
|
||||
const lastMarkTime =
|
||||
this.marks.length > 0 ? this.marks[this.marks.length - 1]!.time : this.startTime;
|
||||
const elapsedTimeSinceLastMark = currentTime - lastMarkTime;
|
||||
|
||||
this.marks.push({ mark: markName, time: elapsedTimeSinceLastMark });
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.endTime !== null) {
|
||||
throw new Error("Perf measurement has already been closed.");
|
||||
}
|
||||
this.endTime = performance.now();
|
||||
}
|
||||
|
||||
result(): { total: number; marks: { mark: string; time: number }[] } {
|
||||
if (this.endTime === null) {
|
||||
throw new Error("Perf measurement has not been closed yet.");
|
||||
}
|
||||
|
||||
const totalTime = this.endTime - this.startTime;
|
||||
return {
|
||||
total: Number.parseFloat(totalTime.toFixed(2)),
|
||||
marks: this.marks.map((mark) => ({
|
||||
mark: mark.mark,
|
||||
time: Number.parseFloat(mark.time.toFixed(2)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
static async execute(fn: () => Promise<any>, times: number = 1): Promise<any> {
|
||||
const perf = Perf.start();
|
||||
|
||||
for (let i = 0; i < times; i++) {
|
||||
await fn();
|
||||
perf.mark(`iteration-${i}`);
|
||||
}
|
||||
|
||||
perf.close();
|
||||
return perf.result();
|
||||
}
|
||||
}
|
||||
84
app/src/core/utils/reqres.ts
Normal file
84
app/src/core/utils/reqres.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export function headersToObject(headers: Headers): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
return { ...Object.fromEntries(headers.entries()) };
|
||||
}
|
||||
|
||||
export function pickHeaders(headers: Headers, keys: string[]): Record<string, string> {
|
||||
const obj = headersToObject(headers);
|
||||
const res = {};
|
||||
for (const key of keys) {
|
||||
if (obj[key]) {
|
||||
res[key] = obj[key];
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const replaceUrlParam = (urlString: string, params: Record<string, string>) => {
|
||||
let newString = urlString;
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
const reg = new RegExp(`/:${k}(?:{[^/]+})?`);
|
||||
newString = newString.replace(reg, `/${v}`);
|
||||
}
|
||||
return newString;
|
||||
};
|
||||
|
||||
export function encodeSearch(obj, options?: { prefix?: string; encode?: boolean }) {
|
||||
let str = "";
|
||||
function _encode(str) {
|
||||
return options?.encode ? encodeURIComponent(str) : str;
|
||||
}
|
||||
|
||||
for (const k in obj) {
|
||||
let tmp = obj[k];
|
||||
if (tmp !== void 0) {
|
||||
if (Array.isArray(tmp)) {
|
||||
for (let i = 0; i < tmp.length; i++) {
|
||||
if (str.length > 0) str += "&";
|
||||
str += `${_encode(k)}=${_encode(tmp[i])}`;
|
||||
}
|
||||
} else {
|
||||
if (typeof tmp === "object") {
|
||||
tmp = JSON.stringify(tmp);
|
||||
}
|
||||
|
||||
if (str.length > 0) str += "&";
|
||||
str += `${_encode(k)}=${_encode(tmp)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (options?.prefix || "") + str;
|
||||
}
|
||||
|
||||
export function decodeSearch(str) {
|
||||
function toValue(mix) {
|
||||
if (!mix) return "";
|
||||
const str = decodeURIComponent(mix);
|
||||
if (str === "false") return false;
|
||||
if (str === "true") return true;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
return +str * 0 === 0 ? +str : str;
|
||||
}
|
||||
}
|
||||
|
||||
let tmp: any;
|
||||
let k: string;
|
||||
const out = {};
|
||||
const arr = str.split("&");
|
||||
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
|
||||
while ((tmp = arr.shift())) {
|
||||
tmp = tmp.split("=");
|
||||
k = tmp.shift();
|
||||
if (out[k] !== void 0) {
|
||||
out[k] = [].concat(out[k], toValue(tmp.shift()));
|
||||
} else {
|
||||
out[k] = toValue(tmp.shift());
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
9
app/src/core/utils/sql.ts
Normal file
9
app/src/core/utils/sql.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { isDebug } from "../env";
|
||||
|
||||
export async function formatSql(sql: string): Promise<string> {
|
||||
if (isDebug()) {
|
||||
const { format } = await import("sql-formatter");
|
||||
return format(sql);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
62
app/src/core/utils/strings.ts
Normal file
62
app/src/core/utils/strings.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export function objectToKeyValueArray<T extends Record<string, any>>(obj: T) {
|
||||
return Object.keys(obj).map((key) => ({ key, value: obj[key as keyof T] }));
|
||||
}
|
||||
|
||||
export function ucFirst(str: string): string {
|
||||
if (!str || str.length === 0) return str;
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
export function ucFirstAll(str: string, split: string = " "): string {
|
||||
if (!str || str.length === 0) return str;
|
||||
return str
|
||||
.split(split)
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join(split);
|
||||
}
|
||||
|
||||
export function ucFirstAllSnakeToPascalWithSpaces(str: string, split: string = " "): string {
|
||||
return ucFirstAll(snakeToPascalWithSpaces(str), split);
|
||||
}
|
||||
|
||||
export function randomString(length: number, includeSpecial = false): string {
|
||||
const base = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const special = "!@#$%^&*()_+{}:\"<>?|[];',./`~";
|
||||
const chars = base + (includeSpecial ? special : "");
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string from snake_case to PascalCase with spaces
|
||||
* Example: `snake_to_pascal` -> `Snake To Pascal`
|
||||
*
|
||||
* @param str
|
||||
*/
|
||||
export function snakeToPascalWithSpaces(str: string): string {
|
||||
if (!str || str.length === 0) return str;
|
||||
|
||||
return str
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function pascalToKebab(pascalStr: string): string {
|
||||
return pascalStr.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace simple mustache like {placeholders} in a string
|
||||
*
|
||||
* @param str
|
||||
* @param vars
|
||||
*/
|
||||
export function replaceSimplePlaceholders(str: string, vars: Record<string, any>): string {
|
||||
return str.replace(/\{\$(\w+)\}/g, (match, key) => {
|
||||
return key in vars ? vars[key] : match;
|
||||
});
|
||||
}
|
||||
18
app/src/core/utils/test.ts
Normal file
18
app/src/core/utils/test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
type ConsoleSeverity = "log" | "warn" | "error";
|
||||
const _oldConsoles = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error
|
||||
};
|
||||
|
||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
|
||||
severities.forEach((severity) => {
|
||||
console[severity] = () => null;
|
||||
});
|
||||
}
|
||||
|
||||
export function enableConsoleLog() {
|
||||
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
|
||||
console[severity as ConsoleSeverity] = fn;
|
||||
});
|
||||
}
|
||||
268
app/src/core/utils/typebox/from-schema.ts
Normal file
268
app/src/core/utils/typebox/from-schema.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/*--------------------------------------------------------------------------
|
||||
|
||||
@sinclair/typebox/prototypes
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017-2024 Haydn Paterson (sinclair) <haydn.developer@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
---------------------------------------------------------------------------*/
|
||||
|
||||
import * as Type from "@sinclair/typebox";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Schematics
|
||||
// ------------------------------------------------------------------
|
||||
const IsExact = (value: unknown, expect: unknown) => value === expect;
|
||||
const IsSValue = (value: unknown): value is SValue =>
|
||||
Type.ValueGuard.IsString(value) ||
|
||||
Type.ValueGuard.IsNumber(value) ||
|
||||
Type.ValueGuard.IsBoolean(value);
|
||||
const IsSEnum = (value: unknown): value is SEnum =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
Type.ValueGuard.IsArray(value.enum) &&
|
||||
value.enum.every((value) => IsSValue(value));
|
||||
const IsSAllOf = (value: unknown): value is SAllOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.allOf);
|
||||
const IsSAnyOf = (value: unknown): value is SAnyOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.anyOf);
|
||||
const IsSOneOf = (value: unknown): value is SOneOf =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.oneOf);
|
||||
const IsSTuple = (value: unknown): value is STuple =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
IsExact(value.type, "array") &&
|
||||
Type.ValueGuard.IsArray(value.items);
|
||||
const IsSArray = (value: unknown): value is SArray =>
|
||||
Type.ValueGuard.IsObject(value) &&
|
||||
IsExact(value.type, "array") &&
|
||||
!Type.ValueGuard.IsArray(value.items) &&
|
||||
Type.ValueGuard.IsObject(value.items);
|
||||
const IsSConst = (value: unknown): value is SConst =>
|
||||
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsObject(value["const"]);
|
||||
const IsSString = (value: unknown): value is SString =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "string");
|
||||
const IsSNumber = (value: unknown): value is SNumber =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "number");
|
||||
const IsSInteger = (value: unknown): value is SInteger =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "integer");
|
||||
const IsSBoolean = (value: unknown): value is SBoolean =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "boolean");
|
||||
const IsSNull = (value: unknown): value is SBoolean =>
|
||||
Type.ValueGuard.IsObject(value) && IsExact(value.type, "null");
|
||||
const IsSProperties = (value: unknown): value is SProperties => Type.ValueGuard.IsObject(value);
|
||||
// prettier-ignore
|
||||
const IsSObject = (value: unknown): value is SObject => Type.ValueGuard.IsObject(value) && IsExact(value.type, 'object') && IsSProperties(value.properties) && (value.required === undefined || Type.ValueGuard.IsArray(value.required) && value.required.every((value: unknown) => Type.ValueGuard.IsString(value)))
|
||||
type SValue = string | number | boolean;
|
||||
type SEnum = Readonly<{ enum: readonly SValue[] }>;
|
||||
type SAllOf = Readonly<{ allOf: readonly unknown[] }>;
|
||||
type SAnyOf = Readonly<{ anyOf: readonly unknown[] }>;
|
||||
type SOneOf = Readonly<{ oneOf: readonly unknown[] }>;
|
||||
type SProperties = Record<PropertyKey, unknown>;
|
||||
type SObject = Readonly<{ type: "object"; properties: SProperties; required?: readonly string[] }>;
|
||||
type STuple = Readonly<{ type: "array"; items: readonly unknown[] }>;
|
||||
type SArray = Readonly<{ type: "array"; items: unknown }>;
|
||||
type SConst = Readonly<{ const: SValue }>;
|
||||
type SString = Readonly<{ type: "string" }>;
|
||||
type SNumber = Readonly<{ type: "number" }>;
|
||||
type SInteger = Readonly<{ type: "integer" }>;
|
||||
type SBoolean = Readonly<{ type: "boolean" }>;
|
||||
type SNull = Readonly<{ type: "null" }>;
|
||||
// ------------------------------------------------------------------
|
||||
// FromRest
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromRest<T extends readonly unknown[], Acc extends Type.TSchema[] = []> = (
|
||||
T extends readonly [infer L extends unknown, ...infer R extends unknown[]]
|
||||
? TFromSchema<L> extends infer S extends Type.TSchema
|
||||
? TFromRest<R, [...Acc, S]>
|
||||
: TFromRest<R, [...Acc]>
|
||||
: Acc
|
||||
)
|
||||
function FromRest<T extends readonly unknown[]>(T: T): TFromRest<T> {
|
||||
return T.map((L) => FromSchema(L)) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// FromEnumRest
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromEnumRest<T extends readonly SValue[], Acc extends Type.TSchema[] = []> = (
|
||||
T extends readonly [infer L extends SValue, ...infer R extends SValue[]]
|
||||
? TFromEnumRest<R, [...Acc, Type.TLiteral<L>]>
|
||||
: Acc
|
||||
)
|
||||
function FromEnumRest<T extends readonly SValue[]>(T: T): TFromEnumRest<T> {
|
||||
return T.map((L) => Type.Literal(L)) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// AllOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromAllOf<T extends SAllOf> = (
|
||||
TFromRest<T['allOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TIntersectEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromAllOf<T extends SAllOf>(T: T): TFromAllOf<T> {
|
||||
return Type.IntersectEvaluated(FromRest(T.allOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// AnyOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromAnyOf<T extends SAnyOf> = (
|
||||
TFromRest<T['anyOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromAnyOf<T extends SAnyOf>(T: T): TFromAnyOf<T> {
|
||||
return Type.UnionEvaluated(FromRest(T.anyOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// OneOf
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromOneOf<T extends SOneOf> = (
|
||||
TFromRest<T['oneOf']> extends infer Rest extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Rest>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromOneOf<T extends SOneOf>(T: T): TFromOneOf<T> {
|
||||
return Type.UnionEvaluated(FromRest(T.oneOf), T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Enum
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromEnum<T extends SEnum> = (
|
||||
TFromEnumRest<T['enum']> extends infer Elements extends Type.TSchema[]
|
||||
? Type.TUnionEvaluated<Elements>
|
||||
: Type.TNever
|
||||
)
|
||||
function FromEnum<T extends SEnum>(T: T): TFromEnum<T> {
|
||||
return Type.UnionEvaluated(FromEnumRest(T.enum));
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Tuple
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromTuple<T extends STuple> = (
|
||||
TFromRest<T['items']> extends infer Elements extends Type.TSchema[]
|
||||
? Type.TTuple<Elements>
|
||||
: Type.TTuple<[]>
|
||||
)
|
||||
// prettier-ignore
|
||||
function FromTuple<T extends STuple>(T: T): TFromTuple<T> {
|
||||
return Type.Tuple(FromRest(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Array
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromArray<T extends SArray> = (
|
||||
TFromSchema<T['items']> extends infer Items extends Type.TSchema
|
||||
? Type.TArray<Items>
|
||||
: Type.TArray<Type.TUnknown>
|
||||
)
|
||||
// prettier-ignore
|
||||
function FromArray<T extends SArray>(T: T): TFromArray<T> {
|
||||
return Type.Array(FromSchema(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Const
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
type TFromConst<T extends SConst> = (
|
||||
Type.Ensure<Type.TLiteral<T['const']>>
|
||||
)
|
||||
function FromConst<T extends SConst>(T: T) {
|
||||
return Type.Literal(T.const, T);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Object
|
||||
// ------------------------------------------------------------------
|
||||
type TFromPropertiesIsOptional<
|
||||
K extends PropertyKey,
|
||||
R extends string | unknown,
|
||||
> = unknown extends R ? true : K extends R ? false : true;
|
||||
// prettier-ignore
|
||||
type TFromProperties<T extends SProperties, R extends string | unknown> = Type.Evaluate<{
|
||||
-readonly [K in keyof T]: TFromPropertiesIsOptional<K, R> extends true
|
||||
? Type.TOptional<TFromSchema<T[K]>>
|
||||
: TFromSchema<T[K]>
|
||||
}>
|
||||
// prettier-ignore
|
||||
type TFromObject<T extends SObject> = (
|
||||
TFromProperties<T['properties'], Exclude<T['required'], undefined>[number]> extends infer Properties extends Type.TProperties
|
||||
? Type.TObject<Properties>
|
||||
: Type.TObject<{}>
|
||||
)
|
||||
function FromObject<T extends SObject>(T: T): TFromObject<T> {
|
||||
const properties = globalThis.Object.getOwnPropertyNames(T.properties).reduce((Acc, K) => {
|
||||
return {
|
||||
...Acc,
|
||||
[K]:
|
||||
T.required && T.required.includes(K)
|
||||
? FromSchema(T.properties[K])
|
||||
: Type.Optional(FromSchema(T.properties[K])),
|
||||
};
|
||||
}, {} as Type.TProperties);
|
||||
return Type.Object(properties, T) as never;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// FromSchema
|
||||
// ------------------------------------------------------------------
|
||||
// prettier-ignore
|
||||
export type TFromSchema<T> = (
|
||||
T extends SAllOf ? TFromAllOf<T> :
|
||||
T extends SAnyOf ? TFromAnyOf<T> :
|
||||
T extends SOneOf ? TFromOneOf<T> :
|
||||
T extends SEnum ? TFromEnum<T> :
|
||||
T extends SObject ? TFromObject<T> :
|
||||
T extends STuple ? TFromTuple<T> :
|
||||
T extends SArray ? TFromArray<T> :
|
||||
T extends SConst ? TFromConst<T> :
|
||||
T extends SString ? Type.TString :
|
||||
T extends SNumber ? Type.TNumber :
|
||||
T extends SInteger ? Type.TInteger :
|
||||
T extends SBoolean ? Type.TBoolean :
|
||||
T extends SNull ? Type.TNull :
|
||||
Type.TUnknown
|
||||
)
|
||||
/** Parses a TypeBox type from raw JsonSchema */
|
||||
export function FromSchema<T>(T: T): TFromSchema<T> {
|
||||
// prettier-ignore
|
||||
return (
|
||||
IsSAllOf(T) ? FromAllOf(T) :
|
||||
IsSAnyOf(T) ? FromAnyOf(T) :
|
||||
IsSOneOf(T) ? FromOneOf(T) :
|
||||
IsSEnum(T) ? FromEnum(T) :
|
||||
IsSObject(T) ? FromObject(T) :
|
||||
IsSTuple(T) ? FromTuple(T) :
|
||||
IsSArray(T) ? FromArray(T) :
|
||||
IsSConst(T) ? FromConst(T) :
|
||||
IsSString(T) ? Type.String(T) :
|
||||
IsSNumber(T) ? Type.Number(T) :
|
||||
IsSInteger(T) ? Type.Integer(T) :
|
||||
IsSBoolean(T) ? Type.Boolean(T) :
|
||||
IsSNull(T) ? Type.Null(T) :
|
||||
Type.Unknown(T || {})
|
||||
) as never
|
||||
}
|
||||
206
app/src/core/utils/typebox/index.ts
Normal file
206
app/src/core/utils/typebox/index.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Kind,
|
||||
type ObjectOptions,
|
||||
type SchemaOptions,
|
||||
type Static,
|
||||
type StaticDecode,
|
||||
type StringOptions,
|
||||
type TLiteral,
|
||||
type TLiteralValue,
|
||||
type TObject,
|
||||
type TRecord,
|
||||
type TSchema,
|
||||
type TString,
|
||||
Type,
|
||||
TypeRegistry
|
||||
} from "@sinclair/typebox";
|
||||
import {
|
||||
DefaultErrorFunction,
|
||||
Errors,
|
||||
SetErrorFunction,
|
||||
type ValueErrorIterator
|
||||
} from "@sinclair/typebox/errors";
|
||||
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
|
||||
export type RecursivePartial<T> = {
|
||||
[P in keyof T]?: T[P] extends (infer U)[]
|
||||
? RecursivePartial<U>[]
|
||||
: T[P] extends object | undefined
|
||||
? RecursivePartial<T[P]>
|
||||
: T[P];
|
||||
};
|
||||
|
||||
type ParseOptions = {
|
||||
useDefaults?: boolean;
|
||||
decode?: boolean;
|
||||
onError?: (errors: ValueErrorIterator) => void;
|
||||
forceParse?: boolean;
|
||||
skipMark?: boolean;
|
||||
};
|
||||
|
||||
const validationSymbol = Symbol("tb-parse-validation");
|
||||
|
||||
export class TypeInvalidError extends Error {
|
||||
errors: ValueError[];
|
||||
constructor(
|
||||
public schema: TSchema,
|
||||
public data: unknown,
|
||||
message?: string
|
||||
) {
|
||||
//console.warn("errored schema", JSON.stringify(schema, null, 2));
|
||||
super(message ?? `Invalid: ${JSON.stringify(data)}`);
|
||||
this.errors = [...Errors(schema, data)];
|
||||
}
|
||||
|
||||
first() {
|
||||
return this.errors[0]!;
|
||||
}
|
||||
|
||||
firstToString() {
|
||||
const first = this.first();
|
||||
return `${first.message} at "${first.path}"`;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
message: this.message,
|
||||
schema: this.schema,
|
||||
data: this.data,
|
||||
errors: this.errors
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function stripMark(obj: any) {
|
||||
const newObj = cloneDeep(obj);
|
||||
mark(newObj, false);
|
||||
return newObj;
|
||||
}
|
||||
|
||||
export function mark(obj: any, validated = true) {
|
||||
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
|
||||
if (validated) {
|
||||
obj[validationSymbol] = true;
|
||||
} else {
|
||||
delete obj[validationSymbol];
|
||||
}
|
||||
for (const key in obj) {
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
mark(obj[key], validated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parse<Schema extends TSchema = TSchema>(
|
||||
schema: Schema,
|
||||
data: RecursivePartial<Static<Schema>>,
|
||||
options?: ParseOptions
|
||||
): Static<Schema> {
|
||||
if (!options?.forceParse && typeof data === "object" && validationSymbol in data) {
|
||||
if (options?.useDefaults === false) {
|
||||
return data as Static<typeof schema>;
|
||||
}
|
||||
|
||||
// this is important as defaults are expected
|
||||
return Default(schema, data as any) as Static<Schema>;
|
||||
}
|
||||
|
||||
const parsed = options?.useDefaults === false ? data : Default(schema, data);
|
||||
|
||||
if (Check(schema, parsed)) {
|
||||
options?.skipMark !== true && mark(parsed, true);
|
||||
return parsed as Static<typeof schema>;
|
||||
} else if (options?.onError) {
|
||||
options.onError(Errors(schema, data));
|
||||
} else {
|
||||
throw new TypeInvalidError(schema, data);
|
||||
}
|
||||
|
||||
// @todo: check this
|
||||
return undefined as any;
|
||||
}
|
||||
|
||||
export function parseDecode<Schema extends TSchema = TSchema>(
|
||||
schema: Schema,
|
||||
data: RecursivePartial<StaticDecode<Schema>>
|
||||
): StaticDecode<Schema> {
|
||||
//console.log("parseDecode", schema, data);
|
||||
const parsed = Default(schema, data);
|
||||
|
||||
if (Check(schema, parsed)) {
|
||||
return parsed as StaticDecode<typeof schema>;
|
||||
}
|
||||
//console.log("errors", ...Errors(schema, data));
|
||||
|
||||
throw new TypeInvalidError(schema, data);
|
||||
}
|
||||
|
||||
export function strictParse<Schema extends TSchema = TSchema>(
|
||||
schema: Schema,
|
||||
data: Static<Schema>,
|
||||
options?: ParseOptions
|
||||
): Static<Schema> {
|
||||
return parse(schema, data as any, options);
|
||||
}
|
||||
|
||||
export function registerCustomTypeboxKinds(registry: typeof TypeRegistry) {
|
||||
registry.Set("StringEnum", (schema: any, value: any) => {
|
||||
return typeof value === "string" && schema.enum.includes(value);
|
||||
});
|
||||
}
|
||||
registerCustomTypeboxKinds(TypeRegistry);
|
||||
|
||||
export const StringEnum = <const T extends readonly string[]>(values: T, options?: StringOptions) =>
|
||||
Type.Unsafe<T[number]>({
|
||||
[Kind]: "StringEnum",
|
||||
type: "string",
|
||||
enum: values,
|
||||
...options
|
||||
});
|
||||
|
||||
// key value record compatible with RJSF and typebox inference
|
||||
// acting like a Record, but using an Object with additionalProperties
|
||||
export const StringRecord = <T extends TSchema>(properties: T, options?: ObjectOptions) =>
|
||||
Type.Object({}, { ...options, additionalProperties: properties }) as unknown as TRecord<
|
||||
TString,
|
||||
typeof properties
|
||||
>;
|
||||
|
||||
// fixed value that only be what is given + prefilled
|
||||
export const Const = <T extends TLiteralValue = TLiteralValue>(value: T, options?: SchemaOptions) =>
|
||||
Type.Literal(value, { ...options, default: value, const: value, readOnly: true }) as TLiteral<T>;
|
||||
|
||||
export const StringIdentifier = Type.String({
|
||||
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
minLength: 2,
|
||||
maxLength: 150
|
||||
});
|
||||
|
||||
SetErrorFunction((error) => {
|
||||
if (error?.schema?.errorMessage) {
|
||||
return error.schema.errorMessage;
|
||||
}
|
||||
|
||||
if (error?.schema?.[Kind] === "StringEnum") {
|
||||
return `Expected: ${error.schema.enum.map((e) => `"${e}"`).join(", ")}`;
|
||||
}
|
||||
|
||||
return DefaultErrorFunction(error);
|
||||
});
|
||||
|
||||
export {
|
||||
Type,
|
||||
type Static,
|
||||
type StaticDecode,
|
||||
type TSchema,
|
||||
Kind,
|
||||
type TObject,
|
||||
type ValueError,
|
||||
type SchemaOptions,
|
||||
Value,
|
||||
Default,
|
||||
Errors,
|
||||
Check
|
||||
};
|
||||
8
app/src/core/utils/types.d.ts
vendored
Normal file
8
app/src/core/utils/types.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & NonNullable<unknown>;
|
||||
|
||||
// prettify recursively
|
||||
export type PrettifyRec<T> = {
|
||||
[K in keyof T]: T[K] extends object ? Prettify<T[K]> : T[K];
|
||||
} & NonNullable<unknown>;
|
||||
4
app/src/core/utils/uuid.ts
Normal file
4
app/src/core/utils/uuid.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// generates v4
|
||||
export function uuid(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
6
app/src/core/utils/xml.ts
Normal file
6
app/src/core/utils/xml.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
|
||||
export function xmlToObject(xml: string) {
|
||||
const parser = new XMLParser();
|
||||
return parser.parse(xml);
|
||||
}
|
||||
Reference in New Issue
Block a user