diff options
Diffstat (limited to 'u')
-rw-r--r-- | u/deno.json | 6 | ||||
-rw-r--r-- | u/fn/callable.ts | 22 | ||||
-rw-r--r-- | u/fn/either.ts | 97 | ||||
-rw-r--r-- | u/fn/mod.ts | 2 | ||||
-rw-r--r-- | u/leftpadesque/debug.ts | 11 | ||||
-rw-r--r-- | u/leftpadesque/memoize.ts | 14 | ||||
-rw-r--r-- | u/leftpadesque/mod.ts | 4 | ||||
-rw-r--r-- | u/leftpadesque/object.ts | 2 | ||||
-rw-r--r-- | u/leftpadesque/prepend.ts | 4 | ||||
-rw-r--r-- | u/mod.ts | 5 | ||||
-rw-r--r-- | u/process/env.ts | 36 | ||||
-rw-r--r-- | u/process/mod.ts | 3 | ||||
-rw-r--r-- | u/process/run.ts | 64 | ||||
-rw-r--r-- | u/process/validate_identifier.ts | 24 | ||||
-rw-r--r-- | u/server/activity/fourohfour.ts | 36 | ||||
-rw-r--r-- | u/server/activity/health.ts | 67 | ||||
-rw-r--r-- | u/server/activity/mod.ts | 13 | ||||
-rw-r--r-- | u/server/filter/json.ts | 51 | ||||
-rw-r--r-- | u/server/filter/method.ts | 41 | ||||
-rw-r--r-- | u/server/filter/mod.ts | 33 | ||||
-rw-r--r-- | u/server/mod.ts | 7 | ||||
-rw-r--r-- | u/server/request.ts | 57 | ||||
-rw-r--r-- | u/server/response.ts | 84 | ||||
-rw-r--r-- | u/trace/itrace.ts | 107 | ||||
-rw-r--r-- | u/trace/logger.ts | 108 | ||||
-rw-r--r-- | u/trace/metrics.ts | 143 | ||||
-rw-r--r-- | u/trace/mod.ts | 5 | ||||
-rw-r--r-- | u/trace/trace.ts | 82 | ||||
-rw-r--r-- | u/trace/util.ts | 58 |
29 files changed, 1186 insertions, 0 deletions
diff --git a/u/deno.json b/u/deno.json new file mode 100644 index 0000000..26b08bf --- /dev/null +++ b/u/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@emprespresso/pengueno", + "version": "0.1.0", + "exports": "./mod.ts", + "workspace": ["./*"] +} diff --git a/u/fn/callable.ts b/u/fn/callable.ts new file mode 100644 index 0000000..fc6ea81 --- /dev/null +++ b/u/fn/callable.ts @@ -0,0 +1,22 @@ +// deno-lint-ignore no-explicit-any +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 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/u/fn/either.ts b/u/fn/either.ts new file mode 100644 index 0000000..8b233bf --- /dev/null +++ b/u/fn/either.ts @@ -0,0 +1,97 @@ +import type { BiMapper, Mapper, Supplier } from "@emprespresso/pengueno"; +import { isObject } from "../leftpadesque/mod.ts"; + +type IEitherTag = "IEither"; +const iEitherTag: IEitherTag = "IEither"; + +export interface IEither<E, T> { + readonly _tag: IEitherTag; + mapBoth: <Ee, Tt>( + errBranch: Mapper<E, Ee>, + okBranch: Mapper<T, Tt>, + ) => IEither<Ee, Tt>; + fold: <Tt>(folder: BiMapper<E | undefined, T | undefined, Tt>) => Tt; + moveRight: <Tt>(t: Tt) => IEither<E, Tt>; + mapRight: <Tt>(mapper: Mapper<T, Tt>) => IEither<E, Tt>; + mapLeft: <Ee>(mapper: Mapper<E, Ee>) => IEither<Ee, T>; + flatMap: <Tt>(mapper: Mapper<T, IEither<E, Tt>>) => IEither<E, Tt>; + flatMapAsync: <Tt>( + mapper: Mapper<T, Promise<IEither<E, Tt>>>, + ) => Promise<IEither<E, Tt>>; +} + +export class Either<E, T> implements IEither<E, T> { + private constructor( + private readonly err?: E, + private readonly ok?: T, + public readonly _tag: IEitherTag = iEitherTag, + ) {} + + public moveRight<Tt>(t: Tt) { + return this.mapRight(() => t); + } + + public fold<R>(folder: BiMapper<E | undefined, T | undefined, R>): R { + return folder(this.err ?? undefined, this.ok ?? undefined); + } + + public mapBoth<Ee, Tt>( + errBranch: Mapper<E, Ee>, + okBranch: Mapper<T, Tt>, + ): Either<Ee, Tt> { + if (this.err !== undefined) return Either.left(errBranch(this.err)); + return Either.right(okBranch(this.ok!)); + } + + public flatMap<Tt>(mapper: Mapper<T, Either<E, Tt>>): Either<E, Tt> { + if (this.ok !== undefined) return mapper(this.ok); + return Either.left<E, Tt>(this.err!); + } + + public mapRight<Tt>(mapper: Mapper<T, Tt>): IEither<E, Tt> { + if (this.ok !== undefined) return Either.right<E, Tt>(mapper(this.ok)); + return Either.left<E, Tt>(this.err!); + } + + public mapLeft<Ee>(mapper: Mapper<E, Ee>): IEither<Ee, T> { + if (this.err !== undefined) return Either.left<Ee, T>(mapper(this.err)); + return Either.right<Ee, T>(this.ok!); + } + + public async flatMapAsync<Tt>( + mapper: Mapper<T, Promise<IEither<E, Tt>>>, + ): Promise<IEither<E, Tt>> { + if (this.err !== undefined) { + return Promise.resolve(Either.left<E, Tt>(this.err)); + } + return await mapper(this.ok!).catch((err) => Either.left<E, Tt>(err as E)); + } + + static left<E, T>(e: E) { + return new Either<E, T>(e); + } + + static right<E, T>(t: T) { + return new Either<E, T>(undefined, t); + } + + static fromFailable<E, T>(s: Supplier<T>) { + try { + return Either.right<E, T>(s()); + } catch (e) { + return Either.left<E, T>(e as E); + } + } + + static async fromFailableAsync<E, T>(s: Promise<T>) { + try { + return Either.right<E, T>(await s); + } catch (e) { + return Either.left<E, T>(e as E); + } + } +} + +export const isEither = <E, T>(o: unknown): o is IEither<E, T> => { + return isObject(o) && "_tag" in o && o._tag === "IEither"; +}; diff --git a/u/fn/mod.ts b/u/fn/mod.ts new file mode 100644 index 0000000..f0fbe88 --- /dev/null +++ b/u/fn/mod.ts @@ -0,0 +1,2 @@ +export * from "./callable.ts"; +export * from "./either.ts"; diff --git a/u/leftpadesque/debug.ts b/u/leftpadesque/debug.ts new file mode 100644 index 0000000..a9da1f3 --- /dev/null +++ b/u/leftpadesque/debug.ts @@ -0,0 +1,11 @@ +const _hasEnv = !Deno.permissions.querySync({ name: "env" }); + +const _env: "development" | "production" = + _hasEnv && (Deno.env.get("ENVIRONMENT") ?? "").toLowerCase().includes("prod") + ? "production" + : "development"; +export const isProd = () => _env === "production"; + +const _debug = !isProd() || (_hasEnv && + ["y", "t"].some((Deno.env.get("DEBUG") ?? "").toLowerCase().startsWith)); +export const isDebug = () => _debug; diff --git a/u/leftpadesque/memoize.ts b/u/leftpadesque/memoize.ts new file mode 100644 index 0000000..95e6019 --- /dev/null +++ b/u/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.apply(args); + cache.set(key, res); + return res; + }) as F; +}; diff --git a/u/leftpadesque/mod.ts b/u/leftpadesque/mod.ts new file mode 100644 index 0000000..63d8d7a --- /dev/null +++ b/u/leftpadesque/mod.ts @@ -0,0 +1,4 @@ +export * from "./object.ts"; +export * from "./prepend.ts"; +export * from "./debug.ts"; +export * from "./memoize.ts"; diff --git a/u/leftpadesque/object.ts b/u/leftpadesque/object.ts new file mode 100644 index 0000000..73f7f80 --- /dev/null +++ b/u/leftpadesque/object.ts @@ -0,0 +1,2 @@ +export const isObject = (o: unknown): o is object => + typeof o === "object" && !Array.isArray(o) && !!o; diff --git a/u/leftpadesque/prepend.ts b/u/leftpadesque/prepend.ts new file mode 100644 index 0000000..9b77aff --- /dev/null +++ b/u/leftpadesque/prepend.ts @@ -0,0 +1,4 @@ +export const prependWith = (arr: string[], prep: string) => + Array(arr.length * 2).fill(0) + .map((_, i) => i % 2 === 0) + .map((isPrep, i) => isPrep ? prep : arr[i]); diff --git a/u/mod.ts b/u/mod.ts new file mode 100644 index 0000000..8397ce6 --- /dev/null +++ b/u/mod.ts @@ -0,0 +1,5 @@ +export * from "./fn/mod.ts"; +export * from "./leftpadesque/mod.ts"; +export * from "./process/mod.ts"; +export * from "./trace/mod.ts"; +export * from "./server/mod.ts"; diff --git a/u/process/env.ts b/u/process/env.ts new file mode 100644 index 0000000..0e41b4f --- /dev/null +++ b/u/process/env.ts @@ -0,0 +1,36 @@ +import { Either, type IEither } from "@emprespresso/pengueno"; + +export const getRequiredEnv = <V extends string>(name: V): IEither<Error, V> => + Either + .fromFailable<Error, V>(() => Deno.env.get(name) as V) // could throw when no permission. + .flatMap((v) => + (v && Either.right(v)) || + Either.left( + new Error(`environment variable "${name}" is required D:`), + ) + ); + +type ObjectFromList<T extends ReadonlyArray<string>, V = string> = { + [K in (T extends ReadonlyArray<infer U> ? U : never)]: V; +}; + +export const getRequiredEnvVars = <V extends string>(vars: ReadonlyArray<V>) => + vars + .map((envVar) => [envVar, getRequiredEnv(envVar)] as [V, IEither<Error, V>]) + .reduce( + ( + acc: IEither<Error, ObjectFromList<typeof vars>>, + x: [V, IEither<Error, V>], + ) => { + const [envVar, eitherVal] = x; + return acc.flatMap((args) => { + return eitherVal.mapRight((envValue) => + ({ + ...args, + [envVar]: envValue, + }) as ObjectFromList<typeof vars> + ); + }); + }, + Either.right({} as ObjectFromList<typeof vars>), + ); diff --git a/u/process/mod.ts b/u/process/mod.ts new file mode 100644 index 0000000..3f02d46 --- /dev/null +++ b/u/process/mod.ts @@ -0,0 +1,3 @@ +export * from "./env.ts"; +export * from "./run.ts"; +export * from "./validate_identifier.ts"; diff --git a/u/process/run.ts b/u/process/run.ts new file mode 100644 index 0000000..4954438 --- /dev/null +++ b/u/process/run.ts @@ -0,0 +1,64 @@ +import { + Either, + type IEither, + type ITraceable, + LogLevel, + TraceUtil, +} from "@emprespresso/pengueno"; + +export type Command = string[] | string; +type CommandOutputDecoded = { + code: number; + stdoutText: string; + stderrText: string; +}; + +export const getStdout = <Trace>( + c: ITraceable<Command, Trace>, + options: Deno.CommandOptions = {}, +): Promise<IEither<Error, string>> => + c.bimap(TraceUtil.withFunctionTrace(getStdout)) + .map((tCmd) => { + const cmd = tCmd.get(); + tCmd.trace.trace(`:> im gonna run this command! ${cmd}`); + const [exec, ...args] = (typeof cmd === "string") ? cmd.split(" ") : cmd; + return new Deno.Command(exec, { + args, + stdout: "piped", + stderr: "piped", + ...options, + }).output(); + }) + .map((tOut) => + Either.fromFailableAsync<Error, Deno.CommandOutput>(tOut.get()) + ) + .map( + TraceUtil.promiseify((tEitherOut) => + tEitherOut.get().flatMap(({ code, stderr, stdout }) => + Either + .fromFailable<Error, CommandOutputDecoded>(() => { + const stdoutText = new TextDecoder().decode(stdout); + const stderrText = new TextDecoder().decode(stderr); + return { code, stdoutText, stderrText }; + }) + .mapLeft((e) => { + tEitherOut.trace.addTrace(LogLevel.ERROR).trace(`o.o wat ${e}`); + return new Error(`${e}`); + }) + .flatMap((decodedOutput): Either<Error, string> => { + const { code, stdoutText, stderrText } = decodedOutput; + tEitherOut.trace.addTrace(LogLevel.DEBUG).trace( + `stderr hehehe ${stderrText}`, + ); + if (code !== 0) { + const msg = + `i weceived an exit code of ${code} i wanna zewoooo :<`; + tEitherOut.trace.addTrace(LogLevel.ERROR).trace(msg); + return Either.left(new Error(msg)); + } + return Either.right(stdoutText); + }) + ) + ), + ) + .get(); diff --git a/u/process/validate_identifier.ts b/u/process/validate_identifier.ts new file mode 100644 index 0000000..32952a6 --- /dev/null +++ b/u/process/validate_identifier.ts @@ -0,0 +1,24 @@ +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/u/server/activity/fourohfour.ts b/u/server/activity/fourohfour.ts new file mode 100644 index 0000000..6449abd --- /dev/null +++ b/u/server/activity/fourohfour.ts @@ -0,0 +1,36 @@ +import { + type IActivity, + type ITraceable, + JsonResponse, + type PenguenoRequest, + type ServerTrace, +} from "@emprespresso/pengueno"; + +const messages = [ + "(≧ω≦)ゞ Oopsie! This endpoint has gone a-404-dable!", + "。゚(。ノωヽ。)゚。 Meow-t found! Your API call ran away!", + "404-bidden! But like...in a cute way (・`ω´・) !", + "(=①ω①=) This endpoint is hiss-terically missing!", + "┐(´∀`)┌ Whoopsie fluff! No API here!", + "(つ≧▽≦)つ Your data went on a paw-sible vacation!", + "(ꈍᴗꈍ) Uwu~ not found, but found our hearts instead!", + "ヽ(;▽;)ノ Eep! This route has ghosted you~", +]; +const randomFourOhFour = () => messages[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/u/server/activity/health.ts b/u/server/activity/health.ts new file mode 100644 index 0000000..0f54a99 --- /dev/null +++ b/u/server/activity/health.ts @@ -0,0 +1,67 @@ +import { + type IActivity, + type IEither, + 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"); +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 + .bimap(TraceUtil.withFunctionTrace(this.checkHealth)) + .bimap(TraceUtil.withMetricTrace(healthCheckMetric)) + .flatMap((r) => r.move(HealthCheckInput.CHECK).map(this.check)) + .peek(TraceUtil.promiseify((h) => + h.get().fold((err) => { + if (err) { + h.trace.trace(healthCheckMetric.failure); + h.trace.addTrace(LogLevel.ERROR).trace(`${err}`); + return; + } + h.trace.trace(healthCheckMetric.success); + }) + )) + .map(TraceUtil.promiseify((h) => + h.get() + .mapBoth( + () => "oh no, i need to eat more vegetables (。•́︿•̀。)...", + () => "think im healthy!! (✿˘◡˘) ready to do work~", + ) + .fold((errMsg, okMsg) => + new JsonResponse( + req, + errMsg ?? okMsg, + { status: errMsg ? 500 : 200 }, + ) + ) + )) + .get(); + } +} diff --git a/u/server/activity/mod.ts b/u/server/activity/mod.ts new file mode 100644 index 0000000..82d8ec4 --- /dev/null +++ b/u/server/activity/mod.ts @@ -0,0 +1,13 @@ +import type { + ITraceable, + PenguenoRequest, + PenguenoResponse, + ServerTrace, +} from "@emprespresso/pengueno"; + +export interface IActivity { + (req: ITraceable<PenguenoRequest, ServerTrace>): Promise<PenguenoResponse>; +} + +export * from "./health.ts"; +export * from "./fourohfour.ts"; diff --git a/u/server/filter/json.ts b/u/server/filter/json.ts new file mode 100644 index 0000000..4a2961e --- /dev/null +++ b/u/server/filter/json.ts @@ -0,0 +1,51 @@ +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"); +export const jsonModel = <MessageT>( + jsonTransformer: JsonTransformer<MessageT>, +): RequestFilter<MessageT> => +(r: ITraceable<PenguenoRequest, ServerTrace>) => + r.bimap(TraceUtil.withMetricTrace(ParseJsonMetric)) + .map((j) => + Either.fromFailableAsync<Error, MessageT>(j.get().json()) + .then((either) => + either.mapLeft((errReason) => { + j.trace.addTrace(LogLevel.WARN).trace(`${errReason}`); + return new PenguenoError( + "seems to be invalid JSON (>//<) can you fix?", + 400, + ); + }) + ) + ) + .peek( + TraceUtil.promiseify((traceableEither) => + traceableEither.get().mapBoth( + () => traceableEither.trace.trace(ParseJsonMetric.failure), + () => traceableEither.trace.trace(ParseJsonMetric.success), + ) + ), + ) + .map( + TraceUtil.promiseify((traceableEitherJson) => + traceableEitherJson.get() + .mapRight(traceableEitherJson.move) + .flatMap(jsonTransformer) + ), + ) + .get(); diff --git a/u/server/filter/method.ts b/u/server/filter/method.ts new file mode 100644 index 0000000..6b0419d --- /dev/null +++ b/u/server/filter/method.ts @@ -0,0 +1,41 @@ +import { + Either, + type ITraceable, + LogLevel, + PenguenoError, + type PenguenoRequest, + type RequestFilter, + type ServerTrace, + TraceUtil, +} from "@emprespresso/pengueno"; + +type HttpMethod = + | "POST" + | "GET" + | "HEAD" + | "PUT" + | "DELETE" + | "CONNECT" + | "OPTIONS" + | "TRACE" + | "PATCH"; + +export const requireMethod = ( + methods: Array<HttpMethod>, +): RequestFilter<HttpMethod> => +(req: ITraceable<PenguenoRequest, ServerTrace>) => + req.bimap(TraceUtil.withFunctionTrace(requireMethod)) + .move(Promise.resolve(req.get())) + .map(TraceUtil.promiseify((t) => { + const { method: _method } = t.get(); + const method = <HttpMethod> _method; + if (!methods.includes(method)) { + const msg = "that's not how you pet me (⋟﹏⋞)~"; + t.trace.addTrace(LogLevel.WARN).trace(msg); + return Either.left<PenguenoError, HttpMethod>( + new PenguenoError(msg, 405), + ); + } + return Either.right<PenguenoError, HttpMethod>(method); + })) + .get(); diff --git a/u/server/filter/mod.ts b/u/server/filter/mod.ts new file mode 100644 index 0000000..bbf37df --- /dev/null +++ b/u/server/filter/mod.ts @@ -0,0 +1,33 @@ +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(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): Promise<IEither<Err, T>>; +} + +export * from "./method.ts"; +export * from "./json.ts"; diff --git a/u/server/mod.ts b/u/server/mod.ts new file mode 100644 index 0000000..866b5f9 --- /dev/null +++ b/u/server/mod.ts @@ -0,0 +1,7 @@ +import type { LogMetricTraceSupplier } from "@emprespresso/pengueno"; +export type ServerTrace = LogMetricTraceSupplier; + +export * from "./activity/mod.ts"; +export * from "./filter/mod.ts"; +export * from "./response.ts"; +export * from "./request.ts"; diff --git a/u/server/request.ts b/u/server/request.ts new file mode 100644 index 0000000..7aa9917 --- /dev/null +++ b/u/server/request.ts @@ -0,0 +1,57 @@ +import { LogMetricTraceable } 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 extends Request { + private constructor( + _input: URL, + _requestInit: RequestInit, + public readonly id: string, + public readonly at: Date, + ) { + super(_input, _requestInit); + } + + public baseResponseHeaders(): Record<string, string> { + const ServerRequestTime = this.at.getTime(); + const ServerResponseTime = Date.now(); + const DeltaTime = ServerResponseTime - ServerRequestTime; + const RequestId = this.id; + + return Object.entries({ + RequestId, + ServerRequestTime, + ServerResponseTime, + DeltaTime, + Hai: penguenoGreeting(), + }).reduce((acc, [key, val]) => ({ ...acc, [key]: (val.toString()) }), {}); + } + + public static from( + request: Request, + ): LogMetricTraceable<PenguenoRequest> { + const id = crypto.randomUUID(); + const url = new URL(request.url); + const { pathname } = url; + const traceSupplier = () => `[${id} <- ${request.method}'d @ ${pathname}]`; + return LogMetricTraceable + .from( + new PenguenoRequest( + url, + { ...request }, + id, + new Date(), + ), + ) + .bimap((_request) => [_request.get(), traceSupplier]); + } +} diff --git a/u/server/response.ts b/u/server/response.ts new file mode 100644 index 0000000..c21819a --- /dev/null +++ b/u/server/response.ts @@ -0,0 +1,84 @@ +import { + type IEither, + isEither, + type ITraceable, + Metric, + type PenguenoRequest, + type ServerTrace, +} from "@emprespresso/pengueno"; + +export type ResponseBody = object | string; +export type TResponseInit = ResponseInit & { + status: number; + headers?: Record<string, string>; +}; + +const getResponse = ( + req: PenguenoRequest, + opts: TResponseInit, +): TResponseInit => { + return { + ...opts, + headers: { + ...(req.baseResponseHeaders()), + ...(opts?.headers), + "Content-Type": (opts?.headers?.["Content-Type"] ?? "text/plain") + + "; charset=utf-8", + }, + }; +}; + +const ResponseCodeMetrics = [1, 2, 3, 4, 5].map((x) => + Metric.fromName(`response.${x}xx`) +); +export const getResponseMetric = (status: number) => { + const index = (Math.floor(status / 100)) + 1; + return ResponseCodeMetrics[index] ?? ResponseCodeMetrics[5 - 1]; +}; + +export class PenguenoResponse extends Response { + constructor( + req: ITraceable<PenguenoRequest, ServerTrace>, + msg: BodyInit, + opts: TResponseInit, + ) { + const responseOpts = getResponse(req.get(), opts); + const resMetric = getResponseMetric(opts.status); + req.trace.trace(resMetric.count.withValue(1.0)); + responseOpts.headers; + super(msg, responseOpts); + } +} + +export class JsonResponse extends PenguenoResponse { + constructor( + req: ITraceable<PenguenoRequest, ServerTrace>, + e: BodyInit | IEither<ResponseBody, ResponseBody>, + opts: TResponseInit, + ) { + const optsWithJsonContentType = { + ...opts, + headers: { + ...opts?.headers, + "Content-Type": "application/json", + }, + }; + if (isEither<ResponseBody, ResponseBody>(e)) { + super( + req, + JSON.stringify( + e.fold((err, ok) => err ? ({ error: err! }) : ({ ok: ok! })), + ), + optsWithJsonContentType, + ); + return; + } + super( + req, + JSON.stringify( + (Math.floor(opts.status / 100) < 4) ? { ok: e } : { error: e }, + ), + optsWithJsonContentType, + ); + } +} diff --git a/u/trace/itrace.ts b/u/trace/itrace.ts new file mode 100644 index 0000000..e6189d3 --- /dev/null +++ b/u/trace/itrace.ts @@ -0,0 +1,107 @@ +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> { + addTrace: Mapper<ITraceWith<TraceWith>, ITrace<TraceWith>>; + trace: SideEffect<ITraceWith<TraceWith>>; +} + +export type ITraceableTuple<T, TraceWith> = [T, BaseTraceWith | TraceWith]; +export type ITraceableMapper< + T, + U, + TraceWith, + W = ITraceable<T, TraceWith>, +> = ( + w: W, +) => U; + +export interface ITraceable<T, Trace = BaseTraceWith> { + readonly trace: ITrace<Trace>; + get: Supplier<T>; + move: <U>(u: U) => ITraceable<U, Trace>; + map: <U>( + mapper: ITraceableMapper<T, U, Trace>, + ) => ITraceable<U, Trace>; + bimap: <U>( + mapper: ITraceableMapper< + T, + ITraceableTuple<U, Array<Trace> | Trace>, + Trace + >, + ) => ITraceable<U, Trace>; + peek: (peek: ITraceableMapper<T, void, Trace>) => ITraceable<T, Trace>; + flatMap: <U>( + mapper: ITraceableMapper<T, ITraceable<U, Trace>, Trace>, + ) => ITraceable<U, Trace>; + flatMapAsync<U>( + mapper: ITraceableMapper<T, Promise<ITraceable<U, Trace>>, Trace>, + ): ITraceable<Promise<U>, Trace>; +} + +export class TraceableImpl<T, TraceWith> implements ITraceable<T, TraceWith> { + protected constructor( + private readonly item: T, + public readonly trace: ITrace<TraceWith>, + ) {} + + public map<U>( + mapper: ITraceableMapper<T, U, TraceWith>, + ) { + const result = mapper(this); + return new TraceableImpl(result, this.trace); + } + + public flatMap<U>( + mapper: ITraceableMapper< + T, + ITraceable<U, TraceWith>, + TraceWith + >, + ): ITraceable<U, TraceWith> { + return mapper(this); + } + + public flatMapAsync<U>( + mapper: ITraceableMapper< + T, + Promise<ITraceable<U, TraceWith>>, + TraceWith + >, + ): ITraceable<Promise<U>, TraceWith> { + return new TraceableImpl( + mapper(this).then((t) => t.get()), + this.trace, + ); + } + + public peek(peek: ITraceableMapper<T, void, TraceWith>) { + peek(this); + return this; + } + + public move<Tt>(t: Tt): ITraceable<Tt, TraceWith> { + return this.map(() => t); + } + + public bimap<U>( + mapper: ITraceableMapper< + T, + ITraceableTuple<U, Array<TraceWith> | TraceWith>, + TraceWith + >, + ) { + const [item, trace] = mapper(this); + const traces = Array.isArray(trace) ? trace : [trace]; + return new TraceableImpl( + item, + traces.reduce((trace, _trace) => trace.addTrace(_trace), this.trace), + ); + } + + public get() { + return this.item; + } +} diff --git a/u/trace/logger.ts b/u/trace/logger.ts new file mode 100644 index 0000000..a5739c8 --- /dev/null +++ b/u/trace/logger.ts @@ -0,0 +1,108 @@ +import { + isDebug, + type ITrace, + type ITraceWith, + type SideEffect, + type Supplier, +} from "@emprespresso/pengueno"; + +export interface ILogger { + log: (...args: unknown[]) => void; + debug: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} +export enum LogLevel { + UNKNOWN = "UNKNOWN", + INFO = "INFO", + WARN = "WARN", + DEBUG = "DEBUG", + ERROR = "ERROR", +} +const logLevelOrder: Array<LogLevel> = [ + LogLevel.DEBUG, + LogLevel.INFO, + LogLevel.WARN, + LogLevel.ERROR, +]; +export const isLogLevel = (l: string): l is LogLevel => + logLevelOrder.some((level) => <string> level === l); + +const defaultAllowedLevels = () => + [ + LogLevel.UNKNOWN, + ...(isDebug() ? [LogLevel.DEBUG] : []), + LogLevel.INFO, + LogLevel.WARN, + LogLevel.ERROR, + ] as Array<LogLevel>; + +export const logWithLevel = ( + logger: ILogger, + level: LogLevel, +): SideEffect<unknown> => { + switch (level) { + case LogLevel.UNKNOWN: + case LogLevel.INFO: + return logger.log; + case LogLevel.DEBUG: + return logger.debug; + case LogLevel.WARN: + return logger.warn; + case LogLevel.ERROR: + return logger.error; + } +}; + +export type LogTraceSupplier = ITraceWith<Supplier<string>>; + +const defaultTrace = () => `[${new Date().toISOString()}]`; +export const LoggerImpl = console; +export class LogTrace implements ITrace<LogTraceSupplier> { + constructor( + private readonly logger: ILogger = LoggerImpl, + private readonly traces: Array<LogTraceSupplier> = [defaultTrace], + private readonly allowedLevels: Supplier<Array<LogLevel>> = + defaultAllowedLevels, + private readonly defaultLevel: LogLevel = LogLevel.INFO, + ) { + } + + public addTrace(trace: LogTraceSupplier): ITrace<LogTraceSupplier> { + return new LogTrace( + this.logger, + this.traces.concat(trace), + this.allowedLevels, + this.defaultLevel, + ); + } + + public trace(trace: LogTraceSupplier) { + const { line, level: _level } = this.foldTraces(this.traces.concat(trace)); + if (!this.allowedLevels().includes(_level)) return; + + const level = _level === LogLevel.UNKNOWN ? this.defaultLevel : _level; + logWithLevel(this.logger, level)(`[${level}]${line}`); + } + + private foldTraces(traces: Array<LogTraceSupplier>) { + const { line, level } = traces.reduce( + (acc: { line: string; level: number }, t) => { + const val = typeof t === "function" ? t() : t; + if (isLogLevel(val)) { + return { + ...acc, + level: Math.max(logLevelOrder.indexOf(val), acc.level), + }; + } + const prefix = [ + acc.line, + val, + ].join(" "); + return { ...acc, prefix }; + }, + { line: "", level: -1 }, + ); + return { line, level: logLevelOrder[level] ?? LogLevel.UNKNOWN }; + } +} diff --git a/u/trace/metrics.ts b/u/trace/metrics.ts new file mode 100644 index 0000000..4ddde06 --- /dev/null +++ b/u/trace/metrics.ts @@ -0,0 +1,143 @@ +import { + isObject, + type ITrace, + type ITraceWith, + type Mapper, + type SideEffect, + type Supplier, +} from "@emprespresso/pengueno"; + +export enum Unit { + COUNT, + MILLISECONDS, +} + +export interface IMetric { + readonly count: IEmittableMetric; + readonly time: IEmittableMetric; + readonly failure: IMetric; + readonly success: IMetric; + readonly warn: IMetric; + readonly children: Supplier<Array<IMetric>>; + + readonly _tag: "IMetric"; +} +export const isIMetric = (t: unknown): t is IMetric => + isObject(t) && "_tag" in t && t._tag === "IMetric"; + +export interface IEmittableMetric { + readonly name: string; + readonly unit: Unit; + withValue: Mapper<number, MetricValue>; +} + +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: "MetricValue", + }; + } +} + +export class Metric implements IMetric { + constructor( + public readonly count: IEmittableMetric, + public readonly time: IEmittableMetric, + public readonly failure: Metric, + public readonly success: Metric, + public readonly warn: Metric, + public readonly _tag: "IMetric" = "IMetric", + ) {} + + public children() { + return [this.failure, this.success, this.warn]; + } + + static fromName(name: string): Metric { + return new Metric( + new EmittableMetric(`${name}.count`, Unit.COUNT), + new EmittableMetric(`${name}.elapsed`, Unit.MILLISECONDS), + Metric.fromName(`${name}.failure`), + Metric.fromName(`${name}.success`), + Metric.fromName(`${name}.warn`), + ); + } +} + +export interface MetricValue { + readonly name: string; + readonly unit: Unit; + readonly value: number; + readonly emissionTimestamp: number; + readonly _tag: "MetricValue"; +} +export const isMetricValue = (t: unknown): t is MetricValue => + isObject(t) && "_tag" in t && t._tag === "MetricValue"; + +export const isMetricsTraceSupplier = (t: unknown): t is MetricsTraceSupplier => + isMetricValue(t) || isIMetric(t); + +export type MetricsTraceSupplier = ITraceWith<IMetric | MetricValue>; +type MetricTracingTuple = [IMetric, Date]; +export class MetricsTrace implements ITrace<MetricsTraceSupplier> { + constructor( + private readonly metricConsumer: SideEffect<Array<MetricValue>>, + private readonly tracing: Array<MetricTracingTuple> = [], + private readonly flushed: Set<IMetric> = new Set(), + ) {} + + public addTrace(trace: MetricsTraceSupplier) { + if (isMetricValue(trace) || typeof trace === "string") return this; + return new MetricsTrace(this.metricConsumer)._nowTracing(trace); + } + + public trace(metric: MetricsTraceSupplier) { + if (typeof metric === "string") return this; + if (isMetricValue(metric)) { + this.metricConsumer([metric]); + return this; + } + + const foundMetricValues = this.tracing.flatMap(( + [tracing, startedTracing], + ) => + [tracing, ...tracing.children()] + .filter((_tracing) => metric === _tracing) + .flatMap((metric) => [ + this.addMetric(metric, startedTracing), + this.addMetric(tracing, startedTracing), + ]) + ).flatMap((values) => values); + + if (foundMetricValues.length === 0) { + return this._nowTracing(metric); + } + + this.metricConsumer(foundMetricValues); + return this; + } + + private addMetric(metric: IMetric, startedTracing: Date): Array<MetricValue> { + if (this.flushed.has(metric)) { + return []; + } + + this.flushed.add(metric); + return [ + metric.count.withValue(1.0), + metric.time.withValue(Date.now() - startedTracing.getTime()), + ]; + } + + private _nowTracing(metric: IMetric): MetricsTrace { + this.tracing.push([metric, new Date()]); + return this; + } +} diff --git a/u/trace/mod.ts b/u/trace/mod.ts new file mode 100644 index 0000000..0f9b61b --- /dev/null +++ b/u/trace/mod.ts @@ -0,0 +1,5 @@ +export * from "./itrace.ts"; +export * from "./util.ts"; +export * from "./logger.ts"; +export * from "./metrics.ts"; +export * from "./trace.ts"; diff --git a/u/trace/trace.ts b/u/trace/trace.ts new file mode 100644 index 0000000..e942066 --- /dev/null +++ b/u/trace/trace.ts @@ -0,0 +1,82 @@ +import { + isMetricsTraceSupplier, + type ITrace, + type ITraceWith, + LogTrace, + type LogTraceSupplier, + MetricsTrace, + type MetricsTraceSupplier, + type MetricValue, + TraceableImpl, +} from "@emprespresso/pengueno"; + +export class LogTraceable<T> extends TraceableImpl<T, LogTraceSupplier> { + public static LogTrace = new LogTrace(); + static from<T>(t: T) { + return new LogTraceable(t, LogTraceable.LogTrace); + } +} + +const getEmbeddedMetricConsumer = + (logTrace: LogTrace) => (metrics: Array<MetricValue>) => + logTrace.addTrace("<metrics>").trace( + JSON.stringify(metrics, null, 2) + "</metrics>", + ); +export class EmbeddedMetricsTraceable<T> + extends TraceableImpl<T, MetricsTraceSupplier> { + public static MetricsTrace = new MetricsTrace( + getEmbeddedMetricConsumer(LogTraceable.LogTrace), + ); + + static from<T>(t: T) { + return new EmbeddedMetricsTraceable( + t, + EmbeddedMetricsTraceable.MetricsTrace, + ); + } +} + +export type LogMetricTraceSupplier = ITraceWith< + LogTraceSupplier | MetricsTraceSupplier +>; +export class LogMetricTrace implements ITrace<LogMetricTraceSupplier> { + constructor( + private logTrace: ITrace<LogTraceSupplier>, + private metricsTrace: ITrace<MetricsTraceSupplier>, + ) {} + + public addTrace( + trace: LogTraceSupplier | MetricsTraceSupplier, + ): LogMetricTrace { + if (isMetricsTraceSupplier(trace)) { + this.metricsTrace = this.metricsTrace.addTrace(trace); + return this; + } + this.logTrace = this.logTrace.addTrace(trace); + return this; + } + + 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> { + public static LogMetricTrace = new LogMetricTrace( + LogTraceable.LogTrace, + EmbeddedMetricsTraceable.MetricsTrace, + ); + + static from<T>(t: T) { + return new LogMetricTraceable( + t, + LogMetricTraceable.LogMetricTrace, + ); + } +} diff --git a/u/trace/util.ts b/u/trace/util.ts new file mode 100644 index 0000000..302c8e4 --- /dev/null +++ b/u/trace/util.ts @@ -0,0 +1,58 @@ +import type { + Callable, + IMetric, + ITraceableMapper, + ITraceableTuple, + MetricsTraceSupplier, +} from "@emprespresso/pengueno"; + +export class TraceUtil { + static withTrace<T, Trace>( + trace: string, + ): ITraceableMapper< + T, + ITraceableTuple<T, Trace | Array<Trace>>, + Trace + > { + return (t) => [t.get(), `[${trace}]`]; + } + + static withMetricTrace<T, Trace extends MetricsTraceSupplier>( + metric: IMetric, + ): ITraceableMapper< + T, + ITraceableTuple<T, Trace | Array<Trace>>, + Trace + > { + return (t) => [t.get(), metric as Trace]; + } + + static withFunctionTrace<F extends Callable, T, Trace>( + f: F, + ): ITraceableMapper< + T, + ITraceableTuple<T, Trace | Array<Trace>>, + Trace + > { + return TraceUtil.withTrace(f.name); + } + + static withClassTrace<C extends object, T, Trace>( + c: C, + ): ITraceableMapper< + T, + ITraceableTuple<T, Trace | Array<Trace>>, + Trace + > { + return TraceUtil.withTrace(c.constructor.name); + } + + 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(); + } +} |