summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-07-27 17:03:10 -0700
committerElizabeth Hunt <me@liz.coffee>2025-07-27 18:30:30 -0700
commit9970036d203ba2d0a46b35ba6fad21d49441cdd4 (patch)
treea585d13933bf4149dcb07e28526063d071453105 /lib
downloadpengueno-9970036d203ba2d0a46b35ba6fad21d49441cdd4.tar.gz
pengueno-9970036d203ba2d0a46b35ba6fad21d49441cdd4.zip
hai
Diffstat (limited to 'lib')
-rw-r--r--lib/index.ts7
-rw-r--r--lib/leftpadesque/debug.ts8
-rw-r--r--lib/leftpadesque/index.ts3
-rw-r--r--lib/leftpadesque/memoize.ts14
-rw-r--r--lib/leftpadesque/prepend.ts5
-rw-r--r--lib/process/argv.ts79
-rw-r--r--lib/process/env.ts25
-rw-r--r--lib/process/exec.ts86
-rw-r--r--lib/process/index.ts5
-rw-r--r--lib/process/signals.ts49
-rw-r--r--lib/process/validate_identifier.ts18
-rw-r--r--lib/server/activity/fourohfour.ts28
-rw-r--r--lib/server/activity/health.ts49
-rw-r--r--lib/server/activity/index.ts8
-rw-r--r--lib/server/filter/index.ts34
-rw-r--r--lib/server/filter/json.ts42
-rw-r--r--lib/server/filter/method.ts30
-rw-r--r--lib/server/http/body.ts10
-rw-r--r--lib/server/http/index.ts3
-rw-r--r--lib/server/http/method.ts1
-rw-r--r--lib/server/http/status.ts71
-rw-r--r--lib/server/index.ts13
-rw-r--r--lib/server/request/index.ts18
-rw-r--r--lib/server/request/pengueno.ts44
-rw-r--r--lib/server/response/index.ts17
-rw-r--r--lib/server/response/pengueno.ts81
-rw-r--r--lib/trace/index.ts5
-rw-r--r--lib/trace/itrace.ts91
-rw-r--r--lib/trace/log/ansi.ts15
-rw-r--r--lib/trace/log/index.ts5
-rw-r--r--lib/trace/log/level.ts19
-rw-r--r--lib/trace/log/logger.ts5
-rw-r--r--lib/trace/log/pretty_json_console.ts39
-rw-r--r--lib/trace/log/trace.ts60
-rw-r--r--lib/trace/metric/emittable.ts18
-rw-r--r--lib/trace/metric/index.ts41
-rw-r--r--lib/trace/metric/metric.ts54
-rw-r--r--lib/trace/metric/trace.ts59
-rw-r--r--lib/trace/trace.ts77
-rw-r--r--lib/trace/util.ts59
-rw-r--r--lib/types/collections/cons.ts108
-rw-r--r--lib/types/collections/index.ts1
-rw-r--r--lib/types/fn/callable.ts21
-rw-r--r--lib/types/fn/either.ts143
-rw-r--r--lib/types/fn/index.ts3
-rw-r--r--lib/types/fn/optional.ts93
-rw-r--r--lib/types/index.ts5
-rw-r--r--lib/types/misc.ts3
-rw-r--r--lib/types/object.ts1
-rw-r--r--lib/types/tagged.ts8
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);