diff options
Diffstat (limited to 'lib')
50 files changed, 1681 insertions, 0 deletions
diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..afcb6d4 --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,7 @@ +import 'module-alias/register'; + +export * from './leftpadesque'; +export * from './types'; +export * from './trace'; +export * from './process'; +export * from './server'; diff --git a/lib/leftpadesque/debug.ts b/lib/leftpadesque/debug.ts new file mode 100644 index 0000000..074e567 --- /dev/null +++ b/lib/leftpadesque/debug.ts @@ -0,0 +1,8 @@ +const _hasEnv = true; // Node.js always has access to environment variables + +const _env: 'development' | 'production' = + _hasEnv && (process.env.ENVIRONMENT ?? '').toLowerCase().includes('prod') ? 'production' : 'development'; +export const isProd = () => _env === 'production'; + +const _debug = !isProd() || (_hasEnv && ['y', 't'].some((process.env.DEBUG ?? '').toLowerCase().startsWith)); +export const isDebug = () => _debug; diff --git a/lib/leftpadesque/index.ts b/lib/leftpadesque/index.ts new file mode 100644 index 0000000..1a644cb --- /dev/null +++ b/lib/leftpadesque/index.ts @@ -0,0 +1,3 @@ +export * from './prepend'; +export * from './debug'; +export * from './memoize'; diff --git a/lib/leftpadesque/memoize.ts b/lib/leftpadesque/memoize.ts new file mode 100644 index 0000000..2f0e87a --- /dev/null +++ b/lib/leftpadesque/memoize.ts @@ -0,0 +1,14 @@ +import type { Callable } from '@emprespresso/pengueno'; + +export const memoize = <R, F extends Callable<R>>(fn: F): F => { + const cache = new Map<string, R>(); + return ((...args: unknown[]): R => { + const key = JSON.stringify(args); + if (cache.has(key)) { + return cache.get(key)!; + } + const res = fn(...args); + cache.set(key, res); + return res; + }) as F; +}; diff --git a/lib/leftpadesque/prepend.ts b/lib/leftpadesque/prepend.ts new file mode 100644 index 0000000..1819536 --- /dev/null +++ b/lib/leftpadesque/prepend.ts @@ -0,0 +1,5 @@ +export const prependWith = (arr: string[], prep: string) => + Array(arr.length * 2) + .fill(0) + .map((_, i) => i % 2 === 0) + .map((isPrep, i) => (isPrep ? prep : arr[Math.floor(i / 2)]!)); diff --git a/lib/process/argv.ts b/lib/process/argv.ts new file mode 100644 index 0000000..396fa96 --- /dev/null +++ b/lib/process/argv.ts @@ -0,0 +1,79 @@ +import { Either, type Mapper, type IEither, Optional } from '@emprespresso/pengueno'; + +export const isArgKey = <K extends string>(k: string): k is K => k.startsWith('--'); + +interface ArgHandler<V> { + absent?: V; + unspecified?: V; + present: Mapper<string, V>; +} + +export const getArg = <K extends string, V>( + arg: K, + argv: Array<string>, + whenValue: ArgHandler<V>, +): IEither<Error, V> => { + const argIndex = Optional.from(argv.findIndex((_argv) => isArgKey(_argv) && _argv.split('=')[0] === arg)).filter( + (index) => index >= 0 && index < argv.length, + ); + if (!argIndex.present()) { + return Optional.from(whenValue.absent) + .map((v) => Either.right<Error, V>(v)) + .orSome(() => + Either.left( + new Error(`arg ${arg} is not present in arguments list and does not have an 'absent' value`), + ), + ) + .get(); + } + + return argIndex + .flatMap((idx) => + Optional.from(argv.at(idx)).map((_argv) => (_argv.includes('=') ? _argv.split('=')[1] : argv.at(idx + 1))), + ) + .filter((next) => !isArgKey(next)) + .map((next) => whenValue.present(next)) + .orSome(() => whenValue.unspecified) + .map((v) => Either.right<Error, V>(<V>v)) + .get(); +}; + +type MappedArgs< + Args extends ReadonlyArray<string>, + Handlers extends Partial<Record<Args[number], ArgHandler<unknown>>>, +> = { + [K in Args[number]]: K extends keyof Handlers ? (Handlers[K] extends ArgHandler<infer T> ? T : string) : string; +}; + +export const argv = < + const Args extends ReadonlyArray<string>, + const Handlers extends Partial<Record<Args[number], ArgHandler<unknown>>>, +>( + args: Args, + handlers?: Handlers, + argv = process.argv.slice(2), +): IEither<Error, MappedArgs<Args, Handlers>> => { + type Result = MappedArgs<Args, Handlers>; + + const defaultHandler: ArgHandler<string> = { present: (value: string) => value }; + + const processArg = (arg: Args[number]): IEither<Error, [Args[number], unknown]> => { + const handler = handlers?.[arg] ?? defaultHandler; + return getArg(arg, argv, handler).mapRight((value) => [arg, value] as const); + }; + + const res = args + .map(processArg) + .reduce( + (acc: IEither<Error, Partial<Result>>, current: IEither<Error, [Args[number], unknown]>) => + acc.flatMap((accValue) => + current.mapRight(([key, value]) => ({ + ...accValue, + [key]: value, + })), + ), + Either.right(<Partial<Result>>{}), + ) + .mapRight((result) => <Result>result); + return res; +}; diff --git a/lib/process/env.ts b/lib/process/env.ts new file mode 100644 index 0000000..f59fadf --- /dev/null +++ b/lib/process/env.ts @@ -0,0 +1,25 @@ +import { IOptional, Either, Optional, type IEither, type ObjectFromList } from '@emprespresso/pengueno'; + +// type safe environment variables + +export const getEnv = (name: string): IOptional<string> => Optional.from(process.env[name]); + +export const getRequiredEnv = <V extends string>(name: V): IEither<Error, string> => + Either.fromFailable(() => getEnv(name).get()).mapLeft( + () => new Error(`environment variable "${name}" is required D:`), + ); + +export const getRequiredEnvVars = <V extends string>(vars: Array<V>): IEither<Error, ObjectFromList<typeof vars>> => { + type Environment = ObjectFromList<typeof vars>; + const emptyEnvironment = Either.right<Error, Environment>(<Environment>{}); + const addTo = (env: Environment, key: V, val: string) => + <Environment>{ + ...env, + [key]: val, + }; + return vars.reduce( + (environment, key) => + environment.joinRight(getRequiredEnv(key), (value, environment) => addTo(environment, key, value)), + emptyEnvironment, + ); +}; diff --git a/lib/process/exec.ts b/lib/process/exec.ts new file mode 100644 index 0000000..f8d572c --- /dev/null +++ b/lib/process/exec.ts @@ -0,0 +1,86 @@ +import { + Either, + IEither, + type ITraceable, + LogLevel, + LogMetricTraceSupplier, + Metric, + TraceUtil, +} from '@emprespresso/pengueno'; +import { exec } from 'node:child_process'; + +export type Command = string[] | string; +export type StdStreams = { stdout: string; stderr: string }; + +export const CmdMetric = Metric.fromName('Exec').asResult(); +type Environment = Record<string, string>; +type Options = { streamTraceable?: Array<'stdout' | 'stderr'>; env?: Environment; clearEnv?: boolean }; +export const getStdout = ( + cmd: ITraceable<Command, LogMetricTraceSupplier>, + options: Options = { streamTraceable: [] }, +): Promise<IEither<Error, string>> => + cmd + .flatMap(TraceUtil.withFunctionTrace(getStdout)) + .flatMap((tCmd) => tCmd.traceScope(() => `Command = ${tCmd.get()}`)) + .map((tCmd) => { + const cmd = tCmd.get(); + const _exec = typeof cmd === 'string' ? cmd : cmd.join(' '); + const env = options.clearEnv ? options.env : { ...process.env, ...options.env }; + return Either.fromFailableAsync<Error, StdStreams>( + new Promise<StdStreams>((res, rej) => { + const proc = exec(_exec, { env }); + let stdout = ''; + proc.stdout?.on('data', (d: Buffer) => { + const s = d.toString(); + stdout += s; + if (options.streamTraceable?.includes('stdout')) { + tCmd.trace.trace(s); + } + }); + const stderr = ''; + proc.stderr?.on('data', (d: Buffer) => { + const s = d.toString(); + stdout += s; + if (options.streamTraceable?.includes('stderr')) { + tCmd.trace.trace(s); + } + }); + + proc.on('exit', (code) => { + const streams = { stdout, stderr }; + if (code === 0) { + res(streams); + } else { + rej(new Error(`exited with non-zero code: ${code}. ${stderr}`)); + } + }); + }), + ); + }) + .map( + TraceUtil.promiseify((tEitherStdStreams) => + tEitherStdStreams.get().mapRight(({ stderr, stdout }) => { + if (stderr) tEitherStdStreams.trace.traceScope(LogLevel.DEBUG).trace(`StdErr = ${stderr}`); + return stdout; + }), + ), + ) + .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(CmdMetric))) + .get(); + +export const getStdoutMany = ( + cmds: ITraceable<Array<Command>, LogMetricTraceSupplier>, + options: Options = { streamTraceable: [] }, +): Promise<IEither<Error, Array<string>>> => + cmds + .coExtend((t) => t.get()) + .reduce( + async (_result, tCmd) => { + const result = await _result; + return result.joinRightAsync( + () => tCmd.map((cmd) => getStdout(cmd, options)).get(), + (stdout, pre) => pre.concat(stdout), + ); + }, + Promise.resolve(Either.right<Error, Array<string>>([])), + ); diff --git a/lib/process/index.ts b/lib/process/index.ts new file mode 100644 index 0000000..8515324 --- /dev/null +++ b/lib/process/index.ts @@ -0,0 +1,5 @@ +export * from './exec'; +export * from './env'; +export * from './validate_identifier'; +export * from './argv'; +export * from './signals'; diff --git a/lib/process/signals.ts b/lib/process/signals.ts new file mode 100644 index 0000000..c4feb7a --- /dev/null +++ b/lib/process/signals.ts @@ -0,0 +1,49 @@ +import { + Either, + IEither, + IMetric, + ITraceable, + LogMetricTrace, + LogMetricTraceSupplier, + Mapper, + Metric, + Optional, + ResultMetric, + SideEffect, + TraceUtil, +} from '@emprespresso/pengueno'; + +export const SigIntMetric = Metric.fromName('SigInt').asResult(); +export const SigTermMetric = Metric.fromName('SigTerm').asResult(); + +export interface Closeable<TFailure> { + readonly close: SideEffect<SideEffect<TFailure | undefined>>; +} + +export class Signals { + public static async awaitClose<E extends Error>( + t: ITraceable<Closeable<E>, LogMetricTraceSupplier>, + ): Promise<IEither<Error, void>> { + const success: IEither<Error, void> = Either.right(<void>undefined); + return new Promise<IEither<Error, void>>((res) => { + const metricizedInterruptHandler = (metric: ResultMetric) => (err: Error | undefined) => + t + .flatMap(TraceUtil.withMetricTrace(metric)) + .peek((_t) => _t.trace.trace('closing')) + .move( + Optional.from(err) + .map((e) => Either.left<Error, void>(e)) + .orSome(() => success) + .get(), + ) + .flatMap(TraceUtil.traceResultingEither(metric)) + .map((e) => res(e.get())) + .peek((_t) => _t.trace.trace('finished')) + .get(); + const sigintCloser = metricizedInterruptHandler(SigIntMetric); + const sigtermCloser = metricizedInterruptHandler(SigTermMetric); + process.on('SIGINT', () => t.flatMap(TraceUtil.withTrace('SIGINT')).get().close(sigintCloser)); + process.on('SIGTERM', () => t.flatMap(TraceUtil.withTrace('SIGTERM')).get().close(sigtermCloser)); + }); + } +} diff --git a/lib/process/validate_identifier.ts b/lib/process/validate_identifier.ts new file mode 100644 index 0000000..1ff3791 --- /dev/null +++ b/lib/process/validate_identifier.ts @@ -0,0 +1,18 @@ +import { Either, type IEither } from '@emprespresso/pengueno'; + +export const validateIdentifier = (token: string) => { + return /^[a-zA-Z0-9_\-:. \/]+$/.test(token) && !token.includes('..'); +}; + +// ensure {@param obj} is a Record<string, string> with stuff that won't +// have the potential for shell injection, just to be super safe. +type InvalidEntry<K, T> = [K, T]; +export const validateExecutionEntries = <T, K extends symbol | number | string = symbol | number | string>( + obj: Record<K, T>, +): IEither<Array<InvalidEntry<K, T>>, Record<string, string>> => { + const invalidEntries = <Array<InvalidEntry<K, T>>>( + Object.entries(obj).filter((e) => !e.every((x) => typeof x === 'string' && validateIdentifier(x))) + ); + if (invalidEntries.length > 0) return Either.left(invalidEntries); + return Either.right(<Record<string, string>>obj); +}; diff --git a/lib/server/activity/fourohfour.ts b/lib/server/activity/fourohfour.ts new file mode 100644 index 0000000..cd90ba0 --- /dev/null +++ b/lib/server/activity/fourohfour.ts @@ -0,0 +1,28 @@ +import { + type IActivity, + type ITraceable, + JsonResponse, + type PenguenoRequest, + type ServerTrace, +} from '@emprespresso/pengueno'; + +const messages = [ + 'D: meow-t found! your api call ran away!', + '404-bidden! but like...in a cute way >:3 !', + ':< your data went on a paw-sible vacation!', + 'uwu~ not found, but found our hearts instead!', +]; +const randomFourOhFour = () => messages[Math.floor(Math.random() * messages.length)]!; + +export interface IFourOhFourActivity { + fourOhFour: IActivity; +} + +export class FourOhFourActivityImpl implements IFourOhFourActivity { + public fourOhFour(req: ITraceable<PenguenoRequest, ServerTrace>) { + return req + .move(new JsonResponse(req, randomFourOhFour(), { status: 404 })) + .map((resp) => Promise.resolve(resp.get())) + .get(); + } +} diff --git a/lib/server/activity/health.ts b/lib/server/activity/health.ts new file mode 100644 index 0000000..9396490 --- /dev/null +++ b/lib/server/activity/health.ts @@ -0,0 +1,49 @@ +import { + type IActivity, + type IEither, + IMetric, + type ITraceable, + JsonResponse, + LogLevel, + type Mapper, + Metric, + type PenguenoRequest, + type ServerTrace, + TraceUtil, +} from '@emprespresso/pengueno'; + +export enum HealthCheckInput { + CHECK, +} +export enum HealthCheckOutput { + YAASSSLAYQUEEN, +} + +export interface IHealthCheckActivity { + checkHealth: IActivity; +} + +const healthCheckMetric = Metric.fromName('Health').asResult(); +export interface HealthChecker + extends Mapper<ITraceable<HealthCheckInput, ServerTrace>, Promise<IEither<Error, HealthCheckOutput>>> {} +export class HealthCheckActivityImpl implements IHealthCheckActivity { + constructor(private readonly check: HealthChecker) {} + + public checkHealth(req: ITraceable<PenguenoRequest, ServerTrace>) { + return req + .flatMap(TraceUtil.withFunctionTrace(this.checkHealth)) + .flatMap(TraceUtil.withMetricTrace(healthCheckMetric)) + .flatMap((r) => r.move(HealthCheckInput.CHECK).map((input) => this.check(input))) + .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(healthCheckMetric))) + .map( + TraceUtil.promiseify((h) => { + const { status, message } = h.get().fold( + () => ({ status: 500, message: 'err' }), + () => ({ status: 200, message: 'ok' }), + ); + return new JsonResponse(req, message, { status }); + }), + ) + .get(); + } +} diff --git a/lib/server/activity/index.ts b/lib/server/activity/index.ts new file mode 100644 index 0000000..fc7c990 --- /dev/null +++ b/lib/server/activity/index.ts @@ -0,0 +1,8 @@ +import type { ITraceable, PenguenoRequest, PenguenoResponse, ServerTrace } from '@emprespresso/pengueno'; + +export interface IActivity { + (req: ITraceable<PenguenoRequest, ServerTrace>): Promise<PenguenoResponse>; +} + +export * from './health'; +export * from './fourohfour'; diff --git a/lib/server/filter/index.ts b/lib/server/filter/index.ts new file mode 100644 index 0000000..509deb3 --- /dev/null +++ b/lib/server/filter/index.ts @@ -0,0 +1,34 @@ +import { + type IEither, + type ITraceable, + LogLevel, + type PenguenoRequest, + type ServerTrace, +} from '@emprespresso/pengueno'; + +export enum ErrorSource { + USER = LogLevel.WARN, + SYSTEM = LogLevel.ERROR, +} + +export class PenguenoError extends Error { + public readonly source: ErrorSource; + constructor( + override readonly message: string, + public readonly status: number, + ) { + super(message); + this.source = Math.floor(status / 100) === 4 ? ErrorSource.USER : ErrorSource.SYSTEM; + } +} + +export interface RequestFilter< + T, + Err extends PenguenoError = PenguenoError, + RIn = ITraceable<PenguenoRequest, ServerTrace>, +> { + (req: RIn): IEither<Err, T> | Promise<IEither<Err, T>>; +} + +export * from './method'; +export * from './json'; diff --git a/lib/server/filter/json.ts b/lib/server/filter/json.ts new file mode 100644 index 0000000..bc53d47 --- /dev/null +++ b/lib/server/filter/json.ts @@ -0,0 +1,42 @@ +import { + Either, + type IEither, + type ITraceable, + LogLevel, + Metric, + PenguenoError, + type PenguenoRequest, + type RequestFilter, + type ServerTrace, + TraceUtil, +} from '@emprespresso/pengueno'; + +export interface JsonTransformer<R, ParsedJson = unknown> { + (json: ITraceable<ParsedJson, ServerTrace>): IEither<PenguenoError, R>; +} + +const ParseJsonMetric = Metric.fromName('JsonParse').asResult(); +export const jsonModel = + <MessageT>(jsonTransformer: JsonTransformer<MessageT>): RequestFilter<MessageT> => + (r: ITraceable<PenguenoRequest, ServerTrace>) => + r + .flatMap(TraceUtil.withFunctionTrace(jsonModel)) + .flatMap(TraceUtil.withMetricTrace(ParseJsonMetric)) + .map((j) => + Either.fromFailableAsync<Error, MessageT>(<Promise<MessageT>>j.get().req.json()).then((either) => + either.mapLeft((errReason) => { + j.trace.traceScope(LogLevel.WARN).trace(errReason); + return new PenguenoError('seems to be invalid JSON (>//<) can you fix?', 400); + }), + ), + ) + .flatMapAsync(TraceUtil.promiseify(TraceUtil.traceResultingEither(ParseJsonMetric))) + .map( + TraceUtil.promiseify((traceableEitherJson) => + traceableEitherJson + .get() + .mapRight((j) => traceableEitherJson.move(j)) + .flatMap(jsonTransformer), + ), + ) + .get(); diff --git a/lib/server/filter/method.ts b/lib/server/filter/method.ts new file mode 100644 index 0000000..7d6aa76 --- /dev/null +++ b/lib/server/filter/method.ts @@ -0,0 +1,30 @@ +import { + Either, + HttpMethod, + IEither, + type ITraceable, + LogLevel, + PenguenoError, + type PenguenoRequest, + type RequestFilter, + type ServerTrace, + TraceUtil, +} from '@emprespresso/pengueno'; + +export const requireMethod = + (methods: Array<HttpMethod>): RequestFilter<HttpMethod> => + (req: ITraceable<PenguenoRequest, ServerTrace>) => + req + .flatMap(TraceUtil.withFunctionTrace(requireMethod)) + .map((t): IEither<PenguenoError, HttpMethod> => { + const { + req: { method }, + } = t.get(); + if (!methods.includes(method)) { + const msg = "that's not how you pet me (âīšâ)~"; + t.trace.traceScope(LogLevel.WARN).trace(msg); + return Either.left(new PenguenoError(msg, 405)); + } + return Either.right(method); + }) + .get(); diff --git a/lib/server/http/body.ts b/lib/server/http/body.ts new file mode 100644 index 0000000..5fc4caa --- /dev/null +++ b/lib/server/http/body.ts @@ -0,0 +1,10 @@ +export type Body = + | ArrayBuffer + | AsyncIterable<Uint8Array> + | Blob + | FormData + | Iterable<Uint8Array> + | NodeJS.ArrayBufferView + | URLSearchParams + | null + | string; diff --git a/lib/server/http/index.ts b/lib/server/http/index.ts new file mode 100644 index 0000000..147d8c4 --- /dev/null +++ b/lib/server/http/index.ts @@ -0,0 +1,3 @@ +export * from './body'; +export * from './status'; +export * from './method'; diff --git a/lib/server/http/method.ts b/lib/server/http/method.ts new file mode 100644 index 0000000..172d77a --- /dev/null +++ b/lib/server/http/method.ts @@ -0,0 +1 @@ +export type HttpMethod = 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH'; diff --git a/lib/server/http/status.ts b/lib/server/http/status.ts new file mode 100644 index 0000000..15cb30c --- /dev/null +++ b/lib/server/http/status.ts @@ -0,0 +1,71 @@ +export const HttpStatusCodes: Record<number, string> = { + 100: 'Continue', + 101: 'Switching Protocols', + 102: 'Processing (WebDAV)', + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 207: 'Multi-Status (WebDAV)', + 208: 'Already Reported (WebDAV)', + 226: 'IM Used', + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 306: '(Unused)', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect (experimental)', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Request Entity Too Large', + 414: 'Request-URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Requested Range Not Satisfiable', + 417: 'Expectation Failed', + 418: "I'm a teapot (RFC 2324)", + 420: 'Enhance Your Calm (Twitter)', + 422: 'Unprocessable Entity (WebDAV)', + 423: 'Locked (WebDAV)', + 424: 'Failed Dependency (WebDAV)', + 425: 'Reserved for WebDAV', + 426: 'Upgrade Required', + 428: 'Precondition Required', + 429: 'Too Many Requests', + 431: 'Request Header Fields Too Large', + 444: 'No Response (Nginx)', + 449: 'Retry With (Microsoft)', + 450: 'Blocked by Windows Parental Controls (Microsoft)', + 451: 'Unavailable For Legal Reasons', + 499: 'Client Closed Request (Nginx)', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', + 506: 'Variant Also Negotiates (Experimental)', + 507: 'Insufficient Storage (WebDAV)', + 508: 'Loop Detected (WebDAV)', + 509: 'Bandwidth Limit Exceeded (Apache)', + 510: 'Not Extended', + 511: 'Network Authentication Required', + 598: 'Network read timeout error', + 599: 'Network connect timeout error', +}; diff --git a/lib/server/index.ts b/lib/server/index.ts new file mode 100644 index 0000000..99e3839 --- /dev/null +++ b/lib/server/index.ts @@ -0,0 +1,13 @@ +import type { ITraceable, LogMetricTraceSupplier, Mapper } from '@emprespresso/pengueno'; +export type ServerTrace = LogMetricTraceSupplier; + +export * from './http'; +export * from './response'; +export * from './request'; +export * from './activity'; +export * from './filter'; + +import { PenguenoRequest, PenguenoResponse } from '@emprespresso/pengueno'; +export interface Server { + readonly serve: Mapper<ITraceable<PenguenoRequest, ServerTrace>, Promise<PenguenoResponse>>; +} diff --git a/lib/server/request/index.ts b/lib/server/request/index.ts new file mode 100644 index 0000000..0fa1c8d --- /dev/null +++ b/lib/server/request/index.ts @@ -0,0 +1,18 @@ +import { HttpMethod } from '@emprespresso/pengueno'; + +export interface BaseRequest { + url: string; + method: HttpMethod; + + header(): Record<string, string>; + + formData(): Promise<FormData>; + json(): Promise<unknown>; + text(): Promise<string>; + + param(key: string): string | undefined; + query(): Record<string, string>; + queries(): Record<string, string[]>; +} + +export * from './pengueno'; diff --git a/lib/server/request/pengueno.ts b/lib/server/request/pengueno.ts new file mode 100644 index 0000000..31563e9 --- /dev/null +++ b/lib/server/request/pengueno.ts @@ -0,0 +1,44 @@ +import { BaseRequest, ITraceable, ServerTrace } from '@emprespresso/pengueno'; + +const greetings = ['hewwo :D', 'hiya cutie', 'boop!', 'sending virtual hugs!', 'stay pawsitive']; +const penguenoGreeting = () => greetings[Math.floor(Math.random() * greetings.length)]; + +export class PenguenoRequest { + private constructor( + public readonly req: BaseRequest, + private readonly id: string, + private readonly at: Date, + ) {} + + public elapsedTimeMs(after = () => Date.now()): number { + return after() - this.at.getTime(); + } + + public getResponseHeaders(): Record<string, string> { + const RequestId = this.id; + const RequestReceivedUnix = this.at.getTime(); + const RequestHandleUnix = Date.now(); + const DeltaUnix = this.elapsedTimeMs(() => RequestHandleUnix); + const Hai = penguenoGreeting(); + + return Object.entries({ + RequestId, + RequestReceivedUnix, + RequestHandleUnix, + DeltaUnix, + Hai, + }).reduce((acc, [key, val]) => ({ ...acc, [key]: val!.toString() }), {}); + } + + public static from(request: ITraceable<BaseRequest, ServerTrace>): ITraceable<PenguenoRequest, ServerTrace> { + const id = crypto.randomUUID(); + return request.bimap((tRequest) => { + const request = tRequest.get(); + const url = new URL(request.url); + const { pathname } = url; + const trace = `RequestId = ${id}, Method = ${request.method}, Path = ${pathname}`; + + return { item: new PenguenoRequest(request, id, new Date()), trace }; + }); + } +} diff --git a/lib/server/response/index.ts b/lib/server/response/index.ts new file mode 100644 index 0000000..2900f6f --- /dev/null +++ b/lib/server/response/index.ts @@ -0,0 +1,17 @@ +import { Body } from '@emprespresso/pengueno'; + +export interface BaseResponse { + status: number; + statusText: string; + headers: Record<string, string>; + + body(): Body; +} + +export interface ResponseOpts { + status: number; + statusText?: string; + headers?: Record<string, string>; +} + +export * from './pengueno'; diff --git a/lib/server/response/pengueno.ts b/lib/server/response/pengueno.ts new file mode 100644 index 0000000..e15d3f1 --- /dev/null +++ b/lib/server/response/pengueno.ts @@ -0,0 +1,81 @@ +import { + BaseResponse, + Body, + HttpStatusCodes, + isEither, + ITraceable, + Metric, + Optional, + PenguenoRequest, + ResponseOpts, + ServerTrace, +} from '@emprespresso/pengueno'; + +const getHeaders = (req: PenguenoRequest, extraHeaders: Record<string, string>) => { + const optHeaders = { + ...req.getResponseHeaders(), + ...extraHeaders, + }; + optHeaders['Content-Type'] = (optHeaders['Content-Type'] ?? 'text/plain') + '; charset=utf-8'; + return optHeaders; +}; + +const ResponseCodeMetrics = [0, 1, 2, 3, 4, 5].map((x) => Metric.fromName(`response.${x}xx`).asResult()); +export const getResponseMetrics = (status: number, elapsedMs?: number) => { + const index = Math.floor(status / 100); + return ResponseCodeMetrics.flatMap((metric, i) => + Optional.from(i) + .filter((i) => i === index) + .map(() => [metric.count.withValue(1.0)]) + .flatMap((metricValues) => + Optional.from(elapsedMs) + .map((ms) => metricValues.concat(metric.time.withValue(ms))) + .orSome(() => metricValues), + ) + .orSome(() => [metric.count.withValue(0.0)]) + .get(), + ); +}; + +export class PenguenoResponse implements BaseResponse { + public readonly statusText: string; + public readonly status: number; + public readonly headers: Record<string, string>; + + constructor( + req: ITraceable<PenguenoRequest, ServerTrace>, + private readonly _body: Body, + opts: ResponseOpts, + ) { + this.headers = getHeaders(req.get(), opts?.headers ?? {}); + this.status = opts.status; + this.statusText = opts.statusText ?? HttpStatusCodes[this.status]!; + + req.trace.trace(getResponseMetrics(opts.status, req.get().elapsedTimeMs())); + } + + public body() { + return this._body; + } +} + +type Jsonable = any; +export class JsonResponse extends PenguenoResponse { + constructor(req: ITraceable<PenguenoRequest, ServerTrace>, e: Jsonable, _opts: ResponseOpts) { + const opts = { ..._opts, headers: { ..._opts.headers, 'Content-Type': 'application/json' } }; + if (isEither<Jsonable, Jsonable>(e)) { + super( + req, + JSON.stringify( + e.fold( + (error) => ({ error, ok: undefined }), + (ok) => ({ ok }), + ), + ), + opts, + ); + return; + } + super(req, JSON.stringify(Math.floor(opts.status / 100) > 4 ? { error: e } : { ok: e }), opts); + } +} diff --git a/lib/trace/index.ts b/lib/trace/index.ts new file mode 100644 index 0000000..7a37cb6 --- /dev/null +++ b/lib/trace/index.ts @@ -0,0 +1,5 @@ +export * from './itrace'; +export * from './metric'; +export * from './log'; +export * from './trace'; +export * from './util'; diff --git a/lib/trace/itrace.ts b/lib/trace/itrace.ts new file mode 100644 index 0000000..e2019fa --- /dev/null +++ b/lib/trace/itrace.ts @@ -0,0 +1,91 @@ +import type { Mapper, SideEffect, Supplier } from '@emprespresso/pengueno'; + +/** + * the "thing" every Trace writer must "trace()". + */ +type BaseTraceWith = string; +export type ITraceWith<T> = BaseTraceWith | T; +export interface ITrace<TraceWith> { + /** + * creates a new trace scope which inherits from this trace. + */ + traceScope: Mapper<ITraceWith<TraceWith>, ITrace<TraceWith>>; + + /** + * does the tracing. + */ + trace: SideEffect<ITraceWith<TraceWith>>; +} + +export type ITraceableTuple<T, TraceWith> = { item: T; trace: BaseTraceWith | TraceWith }; +export type ITraceableMapper<T, _T, TraceWith> = (w: ITraceable<T, TraceWith>) => _T; + +export interface ITraceable<T, Trace = BaseTraceWith> { + readonly trace: ITrace<Trace>; + readonly get: Supplier<T>; + + readonly move: <_T>(t: _T) => ITraceable<_T, Trace>; + readonly map: <_T>(mapper: ITraceableMapper<T, _T, Trace>) => ITraceable<_T, Trace>; + readonly bimap: <_T>(mapper: ITraceableMapper<T, ITraceableTuple<_T, Trace>, Trace>) => ITraceable<_T, Trace>; + readonly coExtend: <_T>(mapper: ITraceableMapper<T, Array<_T>, Trace>) => Array<ITraceable<_T, Trace>>; + readonly peek: (peek: ITraceableMapper<T, void, Trace>) => ITraceable<T, Trace>; + + readonly traceScope: (mapper: ITraceableMapper<T, Trace, Trace>) => ITraceable<T, Trace>; + + readonly flatMap: <_T>(mapper: ITraceableMapper<T, ITraceable<_T, Trace>, Trace>) => ITraceable<_T, Trace>; + readonly flatMapAsync: <_T>( + mapper: ITraceableMapper<T, Promise<ITraceable<_T, Trace>>, Trace>, + ) => ITraceable<Promise<_T>, Trace>; +} + +export class TraceableImpl<T, Trace> implements ITraceable<T, Trace> { + protected constructor( + private readonly item: T, + public readonly trace: ITrace<Trace>, + ) {} + + public map<_T>(mapper: ITraceableMapper<T, _T, Trace>) { + const result = mapper(this); + return new TraceableImpl(result, this.trace); + } + + public coExtend<_T>(mapper: ITraceableMapper<T, Array<_T>, Trace>): Array<ITraceable<_T, Trace>> { + const results = mapper(this); + return Array.from(results).map((result) => this.move(result)); + } + + public flatMap<_T>(mapper: ITraceableMapper<T, ITraceable<_T, Trace>, Trace>): ITraceable<_T, Trace> { + return mapper(this); + } + + public flatMapAsync<_T>( + mapper: ITraceableMapper<T, Promise<ITraceable<_T, Trace>>, Trace>, + ): ITraceable<Promise<_T>, Trace> { + return new TraceableImpl( + mapper(this).then((t) => t.get()), + this.trace, + ); + } + + public traceScope(mapper: ITraceableMapper<T, Trace, Trace>): ITraceable<T, Trace> { + return new TraceableImpl(this.get(), this.trace.traceScope(mapper(this))); + } + + public peek(peek: ITraceableMapper<T, void, Trace>) { + peek(this); + return this; + } + + public move<_T>(t: _T): ITraceable<_T, Trace> { + return this.map(() => t); + } + + public bimap<_T>(mapper: ITraceableMapper<T, ITraceableTuple<_T, Trace>, Trace>) { + const { item, trace: _trace } = mapper(this); + return this.move(item).traceScope(() => <Trace>_trace); + } + + public get() { + return this.item; + } +} diff --git a/lib/trace/log/ansi.ts b/lib/trace/log/ansi.ts new file mode 100644 index 0000000..7ff16a3 --- /dev/null +++ b/lib/trace/log/ansi.ts @@ -0,0 +1,15 @@ +export const ANSI = { + RESET: '\x1b[0m', + BOLD: '\x1b[1m', + DIM: '\x1b[2m', + RED: '\x1b[31m', + GREEN: '\x1b[32m', + YELLOW: '\x1b[33m', + BLUE: '\x1b[34m', + MAGENTA: '\x1b[35m', + CYAN: '\x1b[36m', + WHITE: '\x1b[37m', + BRIGHT_RED: '\x1b[91m', + BRIGHT_YELLOW: '\x1b[93m', + GRAY: '\x1b[90m', +}; diff --git a/lib/trace/log/index.ts b/lib/trace/log/index.ts new file mode 100644 index 0000000..c416026 --- /dev/null +++ b/lib/trace/log/index.ts @@ -0,0 +1,5 @@ +export * from './ansi'; +export * from './level'; +export * from './logger'; +export * from './pretty_json_console'; +export * from './trace'; diff --git a/lib/trace/log/level.ts b/lib/trace/log/level.ts new file mode 100644 index 0000000..027dd71 --- /dev/null +++ b/lib/trace/log/level.ts @@ -0,0 +1,19 @@ +export enum LogLevel { + UNKNOWN = 'UNKNOWN', + INFO = 'INFO', + WARN = 'WARN', + DEBUG = 'DEBUG', + ERROR = 'ERROR', + SYS = 'SYS', +} + +export const logLevelOrder: Array<LogLevel> = [ + LogLevel.DEBUG, + LogLevel.INFO, + LogLevel.WARN, + LogLevel.ERROR, + LogLevel.SYS, +]; + +export const isLogLevel = (l: unknown): l is LogLevel => + typeof l === 'string' && logLevelOrder.some((level) => level === l); diff --git a/lib/trace/log/logger.ts b/lib/trace/log/logger.ts new file mode 100644 index 0000000..37a7e3f --- /dev/null +++ b/lib/trace/log/logger.ts @@ -0,0 +1,5 @@ +import { LogLevel } from './level'; + +export interface ILogger { + readonly log: (level: LogLevel, ...args: string[]) => void; +} diff --git a/lib/trace/log/pretty_json_console.ts b/lib/trace/log/pretty_json_console.ts new file mode 100644 index 0000000..02cc48c --- /dev/null +++ b/lib/trace/log/pretty_json_console.ts @@ -0,0 +1,39 @@ +import { ANSI, LogLevel, ILogger } from '.'; + +export class PrettyJsonConsoleLogger implements ILogger { + public log(level: LogLevel, ...trace: string[]) { + const message = JSON.stringify( + { + level, + trace, + }, + null, + 4, + ); + const styled = `${this.getStyle(level)}${message}${ANSI.RESET}\n`; + this.getStream(level)(styled); + } + + private getStream(level: LogLevel) { + if (level === LogLevel.ERROR) { + return console.error; + } + return console.log; + } + + private getStyle(level: LogLevel) { + switch (level) { + case LogLevel.UNKNOWN: + case LogLevel.INFO: + return `${ANSI.MAGENTA}`; + case LogLevel.DEBUG: + return `${ANSI.CYAN}`; + case LogLevel.WARN: + return `${ANSI.BRIGHT_YELLOW}`; + case LogLevel.ERROR: + return `${ANSI.BRIGHT_RED}`; + case LogLevel.SYS: + return `${ANSI.DIM}${ANSI.BLUE}`; + } + } +} diff --git a/lib/trace/log/trace.ts b/lib/trace/log/trace.ts new file mode 100644 index 0000000..3f71e06 --- /dev/null +++ b/lib/trace/log/trace.ts @@ -0,0 +1,60 @@ +import { isDebug, ITrace, ITraceWith, memoize, Supplier } from '@emprespresso/pengueno'; +import { ILogger, isLogLevel, LogLevel, logLevelOrder, PrettyJsonConsoleLogger } from '.'; + +export type LogTraceSupplier = ITraceWith<Supplier<string>> | ITraceWith<Error>; + +export class LogTrace implements ITrace<LogTraceSupplier> { + constructor( + private readonly logger: ILogger = new PrettyJsonConsoleLogger(), + private readonly traces: Array<LogTraceSupplier> = [defaultTrace], + private readonly defaultLevel: LogLevel = LogLevel.INFO, + private readonly allowedLevels: Supplier<Set<LogLevel>> = defaultAllowedLevelsSupplier, + ) {} + + public traceScope(trace: LogTraceSupplier): ITrace<LogTraceSupplier> { + return new LogTrace(this.logger, this.traces.concat(trace), this.defaultLevel, this.allowedLevels); + } + + public trace(trace: LogTraceSupplier) { + const { traces, level: _level } = this.foldTraces(this.traces.concat(trace)); + if (!this.allowedLevels().has(_level)) return; + + const level = _level === LogLevel.UNKNOWN ? this.defaultLevel : _level; + this.logger.log(level, ...traces); + } + + private foldTraces(_traces: Array<LogTraceSupplier>) { + const _logTraces = _traces.map((trace) => (typeof trace === 'function' ? trace() : trace)); + const _level = _logTraces + .filter((trace) => isLogLevel(trace)) + .reduce((acc, level) => Math.max(logLevelOrder.indexOf(level), acc), -1); + const level = logLevelOrder[_level] ?? LogLevel.UNKNOWN; + + const traces = _logTraces + .filter((trace) => !isLogLevel(trace)) + .map((trace) => { + if (typeof trace === 'object') { + return `TracedException.Name = ${trace.name}, TracedException.Message = ${trace.message}, TracedException.Stack = ${trace.stack}`; + } + return trace; + }); + return { + level, + traces, + }; + } +} + +const defaultTrace = () => `TimeStamp = ${new Date().toISOString()}`; +const defaultAllowedLevels = memoize( + (isDebug: boolean) => + new Set([ + LogLevel.UNKNOWN, + ...(isDebug ? [LogLevel.DEBUG] : []), + LogLevel.INFO, + LogLevel.WARN, + LogLevel.ERROR, + LogLevel.SYS, + ]), +); +const defaultAllowedLevelsSupplier = () => defaultAllowedLevels(isDebug()); diff --git a/lib/trace/metric/emittable.ts b/lib/trace/metric/emittable.ts new file mode 100644 index 0000000..232cd3a --- /dev/null +++ b/lib/trace/metric/emittable.ts @@ -0,0 +1,18 @@ +import { IEmittableMetric, MetricValue, MetricValueTag, Unit } from '.'; + +export class EmittableMetric implements IEmittableMetric { + constructor( + public readonly name: string, + public readonly unit: Unit, + ) {} + + public withValue(value: number): MetricValue { + return { + name: this.name, + unit: this.unit, + emissionTimestamp: Date.now(), + value, + _tag: MetricValueTag, + }; + } +} diff --git a/lib/trace/metric/index.ts b/lib/trace/metric/index.ts new file mode 100644 index 0000000..aebc890 --- /dev/null +++ b/lib/trace/metric/index.ts @@ -0,0 +1,41 @@ +import { isTagged, Tagged, type Mapper } from '@emprespresso/pengueno'; + +export enum Unit { + COUNT = 'COUNT', + MILLISECONDS = 'MILLISECONDS', +} + +export const MetricValueTag = 'MetricValue' as const; +export type MetricValueTag = typeof MetricValueTag; +export const isMetricValue = (t: unknown): t is MetricValue => isTagged(t, MetricValueTag); +export interface MetricValue extends Tagged<MetricValueTag> { + readonly name: string; + readonly unit: Unit; + readonly value: number; + readonly emissionTimestamp: number; +} + +export interface IEmittableMetric { + readonly name: string; + readonly unit: Unit; + readonly withValue: Mapper<number, MetricValue>; +} + +export const IMetricTag = 'IMetric' as const; +export type IMetricTag = typeof IMetricTag; +export const isIMetric = (t: unknown): t is IMetric => isTagged(t, IMetricTag); +export interface IMetric extends Tagged<IMetricTag> { + readonly count: IEmittableMetric; + readonly time: IEmittableMetric; + readonly parent: undefined | IMetric; +} + +export interface IResultMetric extends IMetric { + readonly failure: IMetric; + readonly success: IMetric; + readonly warn: IMetric; +} + +export * from './emittable'; +export * from './metric'; +export * from './trace'; diff --git a/lib/trace/metric/metric.ts b/lib/trace/metric/metric.ts new file mode 100644 index 0000000..03ae4fe --- /dev/null +++ b/lib/trace/metric/metric.ts @@ -0,0 +1,54 @@ +import { EmittableMetric, IMetric, IMetricTag, IResultMetric, Unit } from '.'; + +class _Tagged { + protected constructor(public readonly _tag = IMetricTag) {} +} + +export class Metric extends _Tagged implements IMetric { + private static DELIM = '.'; + + protected constructor( + public readonly name: string, + public readonly parent: undefined | IMetric = undefined, + public readonly count = new EmittableMetric(Metric.join(name, 'count'), Unit.COUNT), + public readonly time = new EmittableMetric(Metric.join(name, 'time'), Unit.MILLISECONDS), + ) { + super(); + } + + public child(_name: string): Metric { + const childName = Metric.join(this.name, _name); + return new Metric(childName, this); + } + + public asResult() { + return ResultMetric.from(this); + } + + static join(...name: Array<string>) { + return name.join(Metric.DELIM); + } + + static fromName(name: string): Metric { + return new Metric(name); + } +} + +export class ResultMetric extends Metric implements IResultMetric { + protected constructor( + public readonly name: string, + public readonly parent: undefined | IMetric = undefined, + public readonly failure: IMetric, + public readonly success: IMetric, + public readonly warn: IMetric, + ) { + super(name, parent); + } + + static from(metric: Metric) { + const failure = metric.child('failure'); + const success = metric.child('success'); + const warn = metric.child('warn'); + return new ResultMetric(metric.name, metric.parent, failure, success, warn); + } +} diff --git a/lib/trace/metric/trace.ts b/lib/trace/metric/trace.ts new file mode 100644 index 0000000..0c5fe37 --- /dev/null +++ b/lib/trace/metric/trace.ts @@ -0,0 +1,59 @@ +import { IMetric, isIMetric, isMetricValue, ITrace, ITraceWith, MetricValue, SideEffect } from '@emprespresso/pengueno'; + +export type MetricsTraceSupplier = + | ITraceWith<IMetric | MetricValue | undefined> + | Array<ITraceWith<IMetric | MetricValue | undefined>>; +export const isMetricsTraceSupplier = (t: unknown): t is MetricsTraceSupplier => + isMetricValue(t) || isIMetric(t) || (Array.isArray(t) && t.every((_m) => isMetricValue(_m) || isIMetric(_m))); + +export class MetricsTrace implements ITrace<MetricsTraceSupplier> { + constructor( + private readonly metricConsumer: SideEffect<Array<MetricValue>>, + private readonly activeTraces: ReadonlyMap<IMetric, number> = new Map(), + private readonly completedTraces: ReadonlySet<IMetric> = new Set(), + ) {} + + public traceScope(trace: MetricsTraceSupplier): MetricsTrace { + const now = Date.now(); + const metricsToTrace = (Array.isArray(trace) ? trace : [trace]).filter(isIMetric); + + const initialTraces = new Map(metricsToTrace.map((metric) => [metric, now])); + + return new MetricsTrace(this.metricConsumer, initialTraces); + } + + public trace(metrics: MetricsTraceSupplier): MetricsTrace { + if (!metrics || typeof metrics === 'string') { + return this; + } + + const now = Date.now(); + const allMetrics = Array.isArray(metrics) ? metrics : [metrics]; + + // partition the incoming metrics + const valuesToEmit = allMetrics.filter(isMetricValue); + const traceableMetrics = allMetrics.filter(isIMetric); + + const metricsToStart = traceableMetrics.filter((m) => !this.activeTraces.has(m)); + const metricsToEnd = traceableMetrics.filter((m) => this.activeTraces.has(m) && !this.completedTraces.has(m)); + + // the new metrics to emit based on traces ending *now* + const endedMetricValues = metricsToEnd.flatMap((metric) => [ + metric.count.withValue(1.0), + metric.time.withValue(now - this.activeTraces.get(metric)!), + ]); + + const allMetricsToEmit = [...valuesToEmit, ...endedMetricValues]; + if (allMetricsToEmit.length > 0) { + this.metricConsumer(allMetricsToEmit); + } + + // the next immutable state + const nextActiveTraces = new Map([ + ...this.activeTraces, + ...metricsToStart.map((m): [IMetric, number] => [m, now]), + ]); + const nextCompletedTraces = new Set([...this.completedTraces, ...metricsToEnd]); + return new MetricsTrace(this.metricConsumer, nextActiveTraces, nextCompletedTraces); + } +} diff --git a/lib/trace/trace.ts b/lib/trace/trace.ts new file mode 100644 index 0000000..ab7e841 --- /dev/null +++ b/lib/trace/trace.ts @@ -0,0 +1,77 @@ +import { + isMetricsTraceSupplier, + type ITrace, + type ITraceable, + type ITraceWith, + LogLevel, + LogTrace, + type LogTraceSupplier, + MetricsTrace, + type MetricsTraceSupplier, + type MetricValue, + TraceableImpl, +} from '.'; + +export class LogTraceable<T> extends TraceableImpl<T, LogTraceSupplier> { + public static LogTrace = new LogTrace(); + static of<T>(t: T) { + return new LogTraceable(t, LogTraceable.LogTrace); + } +} + +const getEmbeddedMetricConsumer = (logTrace: ITrace<LogTraceSupplier>) => (metrics: Array<MetricValue>) => { + if (metrics.length === 0) return; + logTrace.traceScope(LogLevel.SYS).trace(`Metrics = <metrics>${JSON.stringify(metrics)}</metrics>`); +}; + +export class EmbeddedMetricsTraceable<T> extends TraceableImpl<T, MetricsTraceSupplier> { + public static MetricsTrace = new MetricsTrace(getEmbeddedMetricConsumer(LogTraceable.LogTrace)); + + static of<T>(t: T, metricsTrace = EmbeddedMetricsTraceable.MetricsTrace) { + return new EmbeddedMetricsTraceable(t, metricsTrace); + } +} + +export type LogMetricTraceSupplier = ITraceWith<LogTraceSupplier | MetricsTraceSupplier>; +export class LogMetricTrace implements ITrace<LogMetricTraceSupplier> { + constructor( + private logTrace: ITrace<LogTraceSupplier>, + private metricsTrace: ITrace<MetricsTraceSupplier>, + ) {} + + // public traceScope(trace: LogTraceSupplier | MetricsTraceSupplier): LogMetricTrace { + // if (isMetricsTraceSupplier(trace)) { + // this.metricsTrace = this.metricsTrace.traceScope(trace); + // return this; + // } + // this.logTrace = this.logTrace.traceScope(trace); + // return this; + // } + public traceScope(trace: LogTraceSupplier | MetricsTraceSupplier): LogMetricTrace { + if (isMetricsTraceSupplier(trace)) { + return new LogMetricTrace(this.logTrace, this.metricsTrace.traceScope(trace)); + } + return new LogMetricTrace(this.logTrace.traceScope(trace), this.metricsTrace); + } + + public trace(trace: LogTraceSupplier | MetricsTraceSupplier) { + if (isMetricsTraceSupplier(trace)) { + this.metricsTrace.trace(trace); + return this; + } + this.logTrace.trace(trace); + return this; + } +} + +export class LogMetricTraceable<T> extends TraceableImpl<T, MetricsTraceSupplier | LogTraceSupplier> { + static ofLogTraceable<T>(t: ITraceable<T, LogTraceSupplier>) { + const metricsTrace = new MetricsTrace(getEmbeddedMetricConsumer(t.trace)); + return new LogMetricTraceable(t.get(), new LogMetricTrace(t.trace, metricsTrace)); + } + + static of<T>(t: T) { + const logTrace = LogTraceable.of(t); + return LogMetricTraceable.ofLogTraceable(logTrace); + } +} diff --git a/lib/trace/util.ts b/lib/trace/util.ts new file mode 100644 index 0000000..ec67571 --- /dev/null +++ b/lib/trace/util.ts @@ -0,0 +1,59 @@ +import { + IEither, + IMetric, + isEither, + ITraceable, + ITraceWith, + LogLevel, + ResultMetric, + type Callable, + type ITraceableMapper, +} from '@emprespresso/pengueno'; + +export class TraceUtil { + static promiseify<T, U, Trace>( + mapper: ITraceableMapper<T, U, Trace>, + ): ITraceableMapper<Promise<T>, Promise<U>, Trace> { + return (traceablePromise) => + traceablePromise.flatMapAsync(async (t) => t.move(await t.get()).map(mapper)).get(); + } + + static traceResultingEither<TErr, TOk, Trace>( + metric?: ResultMetric, + warnOnFailure = false, + ): ITraceableMapper<IEither<TErr, TOk>, ITraceable<IEither<TErr, TOk>, Trace>, Trace> { + return (t) => { + if (metric) + t.trace.trace( + t.get().fold( + (_err) => <Trace>(warnOnFailure ? metric.warn : metric.failure), + (_ok) => <Trace>metric.success, + ), + ); + return t.traceScope((_t) => + _t.get().fold( + (_err) => <Trace>(warnOnFailure ? LogLevel.WARN : LogLevel.ERROR), + (_ok) => <Trace>LogLevel.INFO, + ), + ); + }; + } + + static withTrace<T, Trace, _Trace extends ITraceWith<Trace>>( + trace: _Trace, + ): ITraceableMapper<T, ITraceable<T, Trace>, Trace> { + return (t) => t.traceScope(() => <Trace>trace); + } + + static withMetricTrace<T, Trace>(metric: IMetric): ITraceableMapper<T, ITraceable<T, Trace>, Trace> { + return TraceUtil.withTrace(<Trace>metric); + } + + static withFunctionTrace<F extends Callable, T, Trace>(f: F): ITraceableMapper<T, ITraceable<T, Trace>, Trace> { + return TraceUtil.withTrace(<Trace>`fn.${f.name}`); + } + + static withClassTrace<C extends object, T, Trace>(c: C): ITraceableMapper<T, ITraceable<T, Trace>, Trace> { + return TraceUtil.withTrace(<Trace>`class.${c.constructor.name}`); + } +} diff --git a/lib/types/collections/cons.ts b/lib/types/collections/cons.ts new file mode 100644 index 0000000..b671d71 --- /dev/null +++ b/lib/types/collections/cons.ts @@ -0,0 +1,108 @@ +import { IOptional, Mapper, Optional, Supplier } from '@emprespresso/pengueno'; + +export interface ICons<T> extends Iterable<T> { + readonly value: T; + readonly next: IOptional<ICons<T>>; + + readonly replace: Mapper<T, ICons<T>>; + readonly before: Mapper<IOptional<ICons<T>>, ICons<T>>; +} + +export class Cons<T> implements ICons<T> { + constructor( + public readonly value: T, + public readonly next: IOptional<ICons<T>> = Optional.none(), + ) {} + + public before(head: IOptional<ICons<T>>): ICons<T> { + return new Cons<T>(this.value, head); + } + + public replace(_value: T): ICons<T> { + return new Cons<T>(_value, this.next); + } + + *[Symbol.iterator]() { + for (let cur = Optional.some<ICons<T>>(this); cur.present(); cur = cur.flatMap((cur) => cur.next)) { + yield cur.get().value; + } + } + + static addOnto<T>(items: Iterable<T>, tail: IOptional<ICons<T>>): IOptional<ICons<T>> { + return Array.from(items) + .reverse() + .reduce((cons, value) => Optional.from<ICons<T>>(new Cons<T>(value, cons)), tail); + } + + static from<T>(items: Iterable<T>): IOptional<ICons<T>> { + return Cons.addOnto(items, Optional.none()); + } +} + +export interface IZipper<T> extends Iterable<T> { + readonly read: Supplier<IOptional<T>>; + readonly next: Supplier<IOptional<IZipper<T>>>; + readonly previous: Supplier<IOptional<IZipper<T>>>; + + readonly prependChunk: Mapper<Iterable<T>, IZipper<T>>; + readonly prepend: Mapper<T, IZipper<T>>; + readonly remove: Supplier<IZipper<T>>; + readonly replace: Mapper<T, IZipper<T>>; +} + +export class ListZipper<T> implements IZipper<T> { + private constructor( + private readonly reversedPathToHead: IOptional<ICons<T>>, + private readonly currentHead: IOptional<ICons<T>>, + ) {} + + public read(): IOptional<T> { + return this.currentHead.map(({ value }) => value); + } + + public next(): IOptional<IZipper<T>> { + return this.currentHead.map<IZipper<T>>( + (head) => new ListZipper<T>(Optional.some(head.before(this.reversedPathToHead)), head.next), + ); + } + + public previous(): IOptional<IZipper<T>> { + return this.reversedPathToHead.map<IZipper<T>>( + (lastVisited) => new ListZipper<T>(lastVisited.next, Optional.some(lastVisited.before(this.currentHead))), + ); + } + + public prependChunk(values: Iterable<T>): IZipper<T> { + return new ListZipper<T>(Cons.addOnto(Array.from(values).reverse(), this.reversedPathToHead), this.currentHead); + } + + public prepend(value: T): IZipper<T> { + return this.prependChunk([value]); + } + + public remove(): IZipper<T> { + const newHead = this.currentHead.flatMap((right) => right.next); + return new ListZipper<T>(this.reversedPathToHead, newHead); + } + + public replace(value: T): IZipper<T> { + const newHead = this.currentHead.map((right) => right.replace(value)); + return new ListZipper<T>(this.reversedPathToHead, newHead); + } + + *[Symbol.iterator]() { + let head: ListZipper<T> = this; + for (let prev = head.previous(); prev.present(); prev = prev.flatMap((p) => p.previous())) { + head = <ListZipper<T>>prev.get(); + } + if (head.currentHead.present()) yield* head.currentHead.get(); + } + + public collection() { + return Array.from(this); + } + + static from<T>(iterable: Iterable<T>): ListZipper<T> { + return new ListZipper(Optional.none(), Cons.from(iterable)); + } +} diff --git a/lib/types/collections/index.ts b/lib/types/collections/index.ts new file mode 100644 index 0000000..8a12ad8 --- /dev/null +++ b/lib/types/collections/index.ts @@ -0,0 +1 @@ +export * from './cons'; diff --git a/lib/types/fn/callable.ts b/lib/types/fn/callable.ts new file mode 100644 index 0000000..60d747b --- /dev/null +++ b/lib/types/fn/callable.ts @@ -0,0 +1,21 @@ +export interface Callable<T = any, ArgT = any> { + (...args: Array<ArgT>): T; +} + +export interface Supplier<T> extends Callable<T, undefined> { + (): T; +} + +export interface Mapper<T, U> extends Callable<U, T> { + (t: T): U; +} + +export interface Predicate<T> extends Mapper<T, boolean> {} + +export interface BiMapper<T, U, R> extends Callable { + (t: T, u: U): R; +} + +export interface SideEffect<T> extends Mapper<T, void> {} + +export interface BiSideEffect<T, U> extends BiMapper<T, U, void> {} diff --git a/lib/types/fn/either.ts b/lib/types/fn/either.ts new file mode 100644 index 0000000..0f65859 --- /dev/null +++ b/lib/types/fn/either.ts @@ -0,0 +1,143 @@ +import { + BiMapper, + IOptional, + type Mapper, + Optional, + Predicate, + type Supplier, + Tagged, + isTagged, +} from '@emprespresso/pengueno'; + +export const IEitherTag = 'IEither' as const; +export type IEitherTag = typeof IEitherTag; +export const isEither = <E, T>(o: unknown): o is IEither<E, T> => isTagged(o, IEitherTag); +export interface IEither<E, T> extends Tagged<IEitherTag> { + readonly left: Supplier<IOptional<E>>; + readonly right: Supplier<IOptional<T>>; + + readonly mapRight: <_T>(mapper: Mapper<T, _T>) => IEither<E, _T>; + readonly filter: (mapper: Predicate<T>) => IEither<E, T>; + readonly mapLeft: <_E>(mapper: Mapper<E, _E>) => IEither<_E, T>; + readonly mapBoth: <_E, _T>(errBranch: Mapper<E, _E>, okBranch: Mapper<T, _T>) => IEither<_E, _T>; + + readonly flatMap: <_T>(mapper: Mapper<T, IEither<E, _T>>) => IEither<E, _T>; + readonly flatMapAsync: <_T>(mapper: Mapper<T, Promise<IEither<E, _T>>>) => Promise<IEither<E, _T>>; + + readonly moveRight: <_T>(t: _T) => IEither<E, _T>; + readonly fold: <_T>(leftFolder: Mapper<E, _T>, rightFolder: Mapper<T, _T>) => _T; + readonly joinRight: <O, _T>(other: IEither<E, O>, mapper: (a: O, b: T) => _T) => IEither<E, _T>; + readonly joinRightAsync: <O, _T>( + other: (() => Promise<IEither<E, O>>) | Promise<IEither<E, O>>, + mapper: (a: O, b: T) => _T, + ) => Promise<IEither<E, _T>>; +} + +const ELeftTag = 'E.Left' as const; +type ELeftTag = typeof ELeftTag; +export const isLeft = <E>(o: unknown): o is Left<E> => isTagged(o, ELeftTag); +interface Left<E> extends Tagged<ELeftTag> { + err: E; +} + +const ERightTag = 'E.Right' as const; +type ERightTag = typeof ERightTag; +export const isRight = <T>(o: unknown): o is Right<T> => isTagged(o, ERightTag); +interface Right<T> extends Tagged<ERightTag> { + ok: T; +} + +class _Tagged implements Tagged<IEitherTag> { + protected constructor(public readonly _tag = IEitherTag) {} +} + +export class Either<E, T> extends _Tagged implements IEither<E, T> { + protected constructor(private readonly self: Left<E> | Right<T>) { + super(); + } + + public moveRight<_T>(t: _T) { + return this.mapRight(() => t); + } + + public mapBoth<_E, _T>(errBranch: Mapper<E, _E>, okBranch: Mapper<T, _T>): IEither<_E, _T> { + if (isLeft(this.self)) return Either.left(errBranch(this.self.err)); + return Either.right(okBranch(this.self.ok)); + } + + public mapRight<_T>(mapper: Mapper<T, _T>): IEither<E, _T> { + if (isRight(this.self)) return Either.right(mapper(this.self.ok)); + return Either.left(this.self.err); + } + + public mapLeft<_E>(mapper: Mapper<E, _E>): IEither<_E, T> { + if (isLeft(this.self)) return Either.left(mapper(this.self.err)); + return Either.right(this.self.ok); + } + + public flatMap<_T>(mapper: Mapper<T, IEither<E, _T>>): IEither<E, _T> { + if (isRight(this.self)) return mapper(this.self.ok); + return Either.left<E, _T>(this.self.err); + } + + public filter(mapper: Predicate<T>): IEither<E, T> { + if (isLeft(this.self)) return Either.left<E, T>(this.self.err); + return Either.fromFailable<E, T>(() => this.right().filter(mapper).get()); + } + + public async flatMapAsync<_T>(mapper: Mapper<T, Promise<IEither<E, _T>>>): Promise<IEither<E, _T>> { + if (isLeft(this.self)) return Promise.resolve(Either.left(this.self.err)); + return await mapper(this.self.ok).catch((err) => Either.left(err)); + } + + public fold<_T>(leftFolder: Mapper<E, _T>, rightFolder: Mapper<T, _T>): _T { + if (isLeft(this.self)) return leftFolder(this.self.err); + return rightFolder(this.self.ok); + } + + public left(): IOptional<E> { + if (isLeft(this.self)) return Optional.from(this.self.err) as IOptional<E>; + return Optional.none(); + } + + public right(): IOptional<T> { + if (isRight(this.self)) return Optional.from(this.self.ok) as IOptional<T>; + return Optional.none(); + } + + public joinRight<O, _T>(other: IEither<E, O>, mapper: BiMapper<O, T, _T>) { + return this.flatMap((t) => other.mapRight((o) => mapper(o, t))); + } + + public joinRightAsync<O, _T>( + other: Supplier<Promise<IEither<E, O>>> | Promise<IEither<E, O>>, + mapper: BiMapper<O, T, _T>, + ) { + return this.flatMapAsync(async (t) => { + const o = typeof other === 'function' ? other() : other; + return await o.then((other) => other.mapRight((o) => mapper(o, t))); + }); + } + + static left<E, T>(e: E): IEither<E, T> { + return new Either({ err: e, _tag: ELeftTag }); + } + + static right<E, T>(t: T): IEither<E, T> { + return new Either({ ok: t, _tag: ERightTag }); + } + + static fromFailable<E, T>(s: Supplier<T>): IEither<E, T> { + try { + return Either.right(s()); + } catch (e) { + return Either.left(e as E); + } + } + + static async fromFailableAsync<E, T>(s: Supplier<Promise<T>> | Promise<T>): Promise<IEither<E, T>> { + return await (typeof s === 'function' ? s() : s) + .then((t: T) => Either.right<E, T>(t)) + .catch((e: E) => Either.left<E, T>(e)); + } +} diff --git a/lib/types/fn/index.ts b/lib/types/fn/index.ts new file mode 100644 index 0000000..191d538 --- /dev/null +++ b/lib/types/fn/index.ts @@ -0,0 +1,3 @@ +export * from './callable'; +export * from './optional'; +export * from './either'; diff --git a/lib/types/fn/optional.ts b/lib/types/fn/optional.ts new file mode 100644 index 0000000..504e496 --- /dev/null +++ b/lib/types/fn/optional.ts @@ -0,0 +1,93 @@ +import { type Mapper, Predicate, type Supplier, Tagged, isTagged } from '@emprespresso/pengueno'; + +export type MaybeGiven<T> = T | undefined | null; + +export const IOptionalTag = 'IOptional' as const; +export type IOptionalTag = typeof IOptionalTag; +export const isOptional = <T>(o: unknown): o is IOptional<T> => isTagged(o, IOptionalTag); +export class IOptionalEmptyError extends Error {} +export interface IOptional<t, T extends NonNullable<t> = NonNullable<t>> extends Tagged<IOptionalTag>, Iterable<T> { + readonly move: <_T>(t: MaybeGiven<_T>) => IOptional<_T>; + readonly map: <_T>(mapper: Mapper<T, MaybeGiven<_T>>) => IOptional<_T>; + readonly filter: (mapper: Predicate<T>) => IOptional<T>; + readonly flatMap: <_T>(mapper: Mapper<T, MaybeGiven<IOptional<_T>>>) => IOptional<_T>; + readonly orSome: (supplier: Supplier<MaybeGiven<t>>) => IOptional<T>; + readonly get: Supplier<T>; + readonly present: Supplier<boolean>; +} + +type OSomeTag = typeof OSomeTag; +const OSomeTag = 'O.Some' as const; +interface Some<T> extends Tagged<OSomeTag> { + value: NonNullable<T>; +} + +const ONoneTag = 'O.None' as const; +type ONoneTag = typeof ONoneTag; +interface None extends Tagged<ONoneTag> {} + +const isNone = (o: unknown): o is None => isTagged(o, ONoneTag); +const isSome = <T>(o: unknown): o is Some<T> => isTagged(o, OSomeTag); + +class _Tagged implements Tagged<IOptionalTag> { + protected constructor(public readonly _tag = IOptionalTag) {} +} + +export class Optional<t, T extends NonNullable<t> = NonNullable<t>> extends _Tagged implements IOptional<T> { + private constructor(private readonly self: Some<T> | None) { + super(); + } + + public move<_T>(t: MaybeGiven<_T>): IOptional<_T> { + return this.map(() => t); + } + + public orSome(supplier: Supplier<MaybeGiven<t>>): IOptional<T> { + if (isNone(this.self)) return Optional.from(supplier()); + return this; + } + + public get(): T { + if (isNone(this.self)) throw new IOptionalEmptyError('called get() on None optional'); + return this.self.value; + } + + public filter(mapper: Predicate<T>): IOptional<T> { + if (isNone(this.self) || !mapper(this.self.value)) return Optional.none(); + return Optional.some(this.self.value); + } + + public map<_T>(mapper: Mapper<T, MaybeGiven<_T>>): IOptional<_T> { + if (isNone(this.self)) return Optional.none(); + return Optional.from(mapper(this.self.value)) as IOptional<_T>; + } + + public flatMap<_T>(mapper: Mapper<T, MaybeGiven<IOptional<_T>>>): IOptional<_T> { + if (isNone(this.self)) return Optional.none(); + return Optional.from(mapper(this.self.value)) + .orSome(() => Optional.none()) + .get(); + } + + public present() { + return isSome(this.self); + } + + *[Symbol.iterator]() { + if (isSome(this.self)) yield this.self.value; + } + + static some<t, T extends NonNullable<t> = NonNullable<t>>(value: T): IOptional<T> { + return new Optional({ value, _tag: OSomeTag }); + } + + private static readonly _none = new Optional({ _tag: ONoneTag }); + static none<T>(): IOptional<T> { + return this._none as unknown as IOptional<T>; + } + + static from<t, T extends NonNullable<t> = NonNullable<t>>(value: MaybeGiven<t>): IOptional<T> { + if (value === null || value === undefined) return Optional.none<T>(); + return Optional.some(<T>value); + } +} diff --git a/lib/types/index.ts b/lib/types/index.ts new file mode 100644 index 0000000..5c4e4d2 --- /dev/null +++ b/lib/types/index.ts @@ -0,0 +1,5 @@ +export * from './misc'; +export * from './object'; +export * from './tagged'; +export * from './fn'; +export * from './collections'; diff --git a/lib/types/misc.ts b/lib/types/misc.ts new file mode 100644 index 0000000..77833c4 --- /dev/null +++ b/lib/types/misc.ts @@ -0,0 +1,3 @@ +export type ObjectFromList<T extends ReadonlyArray<string | number | symbol>, V = string> = { + [K in T extends ReadonlyArray<infer U> ? U : never]: V; +}; diff --git a/lib/types/object.ts b/lib/types/object.ts new file mode 100644 index 0000000..fe97999 --- /dev/null +++ b/lib/types/object.ts @@ -0,0 +1 @@ +export const isObject = (o: unknown): o is object => typeof o === 'object' && !Array.isArray(o) && !!o; diff --git a/lib/types/tagged.ts b/lib/types/tagged.ts new file mode 100644 index 0000000..31607b0 --- /dev/null +++ b/lib/types/tagged.ts @@ -0,0 +1,8 @@ +import { isObject } from '.'; + +export interface Tagged<TTag> { + _tag: TTag; +} + +export const isTagged = <TTag>(o: unknown, tag: TTag): o is Tagged<TTag> => + !!(isObject(o) && '_tag' in o && o._tag === tag); |