From 58be1809c46cbe517a18d86d0af52179dcc5cbf6 Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Sun, 29 Jun 2025 17:31:30 -0700 Subject: Move to nodejs and also lots of significant refactoring that should've been broken up but idgaf --- index.ts | 16 ++-- model/index.ts | 4 +- model/job/index.ts | 2 +- model/pipeline/builder.ts | 6 +- model/pipeline/impl.ts | 2 +- model/pipeline/index.ts | 6 +- package.json | 2 +- server/ci.ts | 30 ++++---- server/health.ts | 2 +- server/hono_proxy.ts | 71 ++++++++++++++++++ server/index.ts | 32 ++------ server/job/index.ts | 4 +- server/job/queue.ts | 40 +++++----- server/job/run_activity.ts | 55 +++++++------- tsconfig.json | 2 +- u/fn/callable.ts | 20 ----- u/fn/either.ts | 100 ------------------------- u/fn/index.ts | 2 - u/history.ts | 36 --------- u/index.ts | 5 +- u/leftpadesque/index.ts | 1 - u/leftpadesque/object.ts | 1 - u/process/argv.ts | 42 ++++++----- u/process/env.ts | 13 ++-- u/process/index.ts | 1 + u/process/run.ts | 36 ++++----- u/process/signals.ts | 49 ++++++++++++ u/server/activity/health.ts | 40 +++------- u/server/filter/index.ts | 2 +- u/server/filter/json.ts | 20 ++--- u/server/filter/method.ts | 30 ++++---- u/server/http/body.ts | 10 +++ u/server/http/index.ts | 3 + u/server/http/method.ts | 1 + u/server/http/status.ts | 71 ++++++++++++++++++ u/server/index.ts | 12 ++- u/server/request.ts | 39 ---------- u/server/request/index.ts | 18 +++++ u/server/request/pengueno.ts | 44 +++++++++++ u/server/response.ts | 83 --------------------- u/server/response/index.ts | 18 +++++ u/server/response/json_pengueno.ts | 29 ++++++++ u/server/response/pengueno.ts | 59 +++++++++++++++ u/trace/index.ts | 6 +- u/trace/itrace.ts | 75 ++++++++++++------- u/trace/log/ansi.ts | 15 ++++ u/trace/log/index.ts | 5 ++ u/trace/log/level.ts | 19 +++++ u/trace/log/logger.ts | 5 ++ u/trace/log/pretty_json_console.ts | 39 ++++++++++ u/trace/log/trace.ts | 60 +++++++++++++++ u/trace/logger.ts | 126 ------------------------------- u/trace/metric/emittable.ts | 18 +++++ u/trace/metric/index.ts | 41 ++++++++++ u/trace/metric/metric.ts | 54 ++++++++++++++ u/trace/metric/trace.ts | 59 +++++++++++++++ u/trace/metrics.ts | 140 ----------------------------------- u/trace/trace.ts | 14 ++-- u/trace/util.ts | 72 ++++++++++-------- u/types/collections/cons.ts | 40 ++++++++++ u/types/collections/index.ts | 2 + u/types/collections/list_zipper.ts | 70 ++++++++++++++++++ u/types/fn/callable.ts | 19 +++++ u/types/fn/either.ts | 106 ++++++++++++++++++++++++++ u/types/fn/index.ts | 3 + u/types/fn/optional.ts | 93 +++++++++++++++++++++++ u/types/index.ts | 5 ++ u/types/object.ts | 1 + u/types/tagged.ts | 8 ++ worker/executor.ts | 76 ++++++++----------- worker/scripts/ansible_playbook.ts | 4 +- worker/scripts/build_docker_image.ts | 49 +++++------- worker/scripts/checkout_ci.ts | 72 +++++++++--------- worker/secret.ts | 73 ++++++++---------- 74 files changed, 1438 insertions(+), 990 deletions(-) create mode 100644 server/hono_proxy.ts delete mode 100644 u/fn/callable.ts delete mode 100644 u/fn/either.ts delete mode 100644 u/fn/index.ts delete mode 100644 u/history.ts delete mode 100644 u/leftpadesque/object.ts create mode 100644 u/process/signals.ts create mode 100644 u/server/http/body.ts create mode 100644 u/server/http/index.ts create mode 100644 u/server/http/method.ts create mode 100644 u/server/http/status.ts delete mode 100644 u/server/request.ts create mode 100644 u/server/request/index.ts create mode 100644 u/server/request/pengueno.ts delete mode 100644 u/server/response.ts create mode 100644 u/server/response/index.ts create mode 100644 u/server/response/json_pengueno.ts create mode 100644 u/server/response/pengueno.ts create mode 100644 u/trace/log/ansi.ts create mode 100644 u/trace/log/index.ts create mode 100644 u/trace/log/level.ts create mode 100644 u/trace/log/logger.ts create mode 100644 u/trace/log/pretty_json_console.ts create mode 100644 u/trace/log/trace.ts delete mode 100644 u/trace/logger.ts create mode 100644 u/trace/metric/emittable.ts create mode 100644 u/trace/metric/index.ts create mode 100644 u/trace/metric/metric.ts create mode 100644 u/trace/metric/trace.ts delete mode 100644 u/trace/metrics.ts create mode 100644 u/types/collections/cons.ts create mode 100644 u/types/collections/index.ts create mode 100644 u/types/collections/list_zipper.ts create mode 100644 u/types/fn/callable.ts create mode 100644 u/types/fn/either.ts create mode 100644 u/types/fn/index.ts create mode 100644 u/types/fn/optional.ts create mode 100644 u/types/index.ts create mode 100644 u/types/object.ts create mode 100644 u/types/tagged.ts diff --git a/index.ts b/index.ts index a9defca..86119ca 100755 --- a/index.ts +++ b/index.ts @@ -3,7 +3,7 @@ import { argv, IEither, Either } from '@emprespresso/pengueno'; import { runServer } from '@emprespresso/ci_server'; -const main = (_argv = process.argv.slice(2)): Promise> => +const main = (_argv = process.argv.slice(2)): Promise> => argv( ['--run-server', '--port', '--host'], { @@ -18,19 +18,17 @@ const main = (_argv = process.argv.slice(2)): Promise> => port: args['--port'], host: args['--host'], })) - .flatMapAsync((runConfig) => { - if (runConfig.server_mode) { - return runServer(runConfig.port, runConfig.host); + .flatMapAsync(async (runConfig) => { + if (!runConfig.server_mode) { + return Either.right(undefined); } - return Promise.resolve(Either.right(0)); + return runServer(runConfig.port, runConfig.host); }); if (process.argv[1] === import.meta.filename) { await main().then((eitherDone) => - eitherDone.fold(({ isLeft, value }) => { - if (!isLeft) return; - - console.error(`failed to start`, value); + eitherDone.mapLeft((err) => { + console.error(`failed to start`, err); process.exit(1); }), ); diff --git a/model/index.ts b/model/index.ts index 094c693..5e91071 100644 --- a/model/index.ts +++ b/model/index.ts @@ -1,2 +1,2 @@ -export * from './job'; -export * from './pipeline'; +export * from './job/index.js'; +export * from './pipeline/index.js'; diff --git a/model/job/index.ts b/model/job/index.ts index 78f69d6..b7fb3b0 100644 --- a/model/job/index.ts +++ b/model/job/index.ts @@ -8,4 +8,4 @@ export interface Job { export const isJob = (j: unknown): j is Job => !!(isObject(j) && 'arguments' in j && isObject(j.arguments) && 'type' in j && typeof j.type === 'string' && j); -export * from './jobs'; +export * from './jobs.js'; diff --git a/model/pipeline/builder.ts b/model/pipeline/builder.ts index e95e89c..dac9452 100644 --- a/model/pipeline/builder.ts +++ b/model/pipeline/builder.ts @@ -1,6 +1,6 @@ -import { Pipeline, PipelineStage } from '.'; -import { FetchCodeJob } from '../job'; -import { PipelineImpl } from './impl'; +import { Pipeline, PipelineStage } from './index.js'; +import { FetchCodeJob } from '../job/index.js'; +import { PipelineImpl } from './impl.js'; export interface PipelineBuilder { addStage(stage: PipelineStage): PipelineBuilder; diff --git a/model/pipeline/impl.ts b/model/pipeline/impl.ts index 2e08d6e..406a05e 100644 --- a/model/pipeline/impl.ts +++ b/model/pipeline/impl.ts @@ -1,5 +1,5 @@ import { Either, IEither } from '@emprespresso/pengueno'; -import { isPipeline, Pipeline, PipelineStage } from '.'; +import { isPipeline, Pipeline, PipelineStage } from './index.js'; export class PipelineImpl implements Pipeline { constructor(public readonly serialJobs: Array) {} diff --git a/model/pipeline/index.ts b/model/pipeline/index.ts index adf902b..fd14e8d 100644 --- a/model/pipeline/index.ts +++ b/model/pipeline/index.ts @@ -1,5 +1,5 @@ import { isObject } from '@emprespresso/pengueno'; -import { isJob, Job } from '../job'; +import { isJob, Job } from '../job/index.js'; export interface PipelineStage { readonly parallelJobs: Array; @@ -15,5 +15,5 @@ export interface Pipeline { serialize(): string; } -export * from './builder'; -export * from './impl'; +export * from './builder.js'; +export * from './impl.js'; diff --git a/package.json b/package.json index 8107bdf..9ed995f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "type-check": "tsc --noEmit", - "clean": "npm run clean --workspaces --if-present && rm -rf dist", + "clean": "npm run clean --workspaces --if-present && rm -rf dist node_modules", "start": "node dist/index.js" }, "devDependencies": { diff --git a/server/ci.ts b/server/ci.ts index f57c426..c8aa6a1 100644 --- a/server/ci.ts +++ b/server/ci.ts @@ -1,34 +1,41 @@ import { FourOhFourActivityImpl, - getRequiredEnv, + getEnv, HealthCheckActivityImpl, type HealthChecker, type IFourOhFourActivity, type IHealthCheckActivity, type ITraceable, PenguenoRequest, + Server, type ServerTrace, - TraceUtil, } from '@emprespresso/pengueno'; import type { Job } from '@emprespresso/ci_model'; -import { type IJobHookActivity, type IJobQueuer, JobHookActivityImpl, LaminarJobQueuer } from './job'; -import { healthCheck as _healthCheck } from '.'; +import { + healthCheck as _healthCheck, + type IJobHookActivity, + type IJobQueuer, + JobHookActivityImpl, + LaminarJobQueuer, +} from '@emprespresso/ci_server'; export const DEFAULT_CI_SERVER = 'https://ci.liz.coffee'; -export class CiHookServer { +export class CiHookServer implements Server { constructor( healthCheck: HealthChecker = _healthCheck, jobQueuer: IJobQueuer> = new LaminarJobQueuer( - getRequiredEnv('LAMINAR_URL').fold(({ isLeft, value }) => (isLeft ? DEFAULT_CI_SERVER : value)), + getEnv('LAMINAR_URL') + .orSome(() => DEFAULT_CI_SERVER) + .get(), ), private readonly healthCheckActivity: IHealthCheckActivity = new HealthCheckActivityImpl(healthCheck), private readonly jobHookActivity: IJobHookActivity = new JobHookActivityImpl(jobQueuer), private readonly fourOhFourActivity: IFourOhFourActivity = new FourOhFourActivityImpl(), ) {} - private route(req: ITraceable) { - const url = new URL(req.get().url); + public serve(req: ITraceable) { + const url = new URL(req.get().req.url); if (url.pathname === '/health') { return this.healthCheckActivity.checkHealth(req); } @@ -37,11 +44,4 @@ export class CiHookServer { } return this.fourOhFourActivity.fourOhFour(req); } - - public serve(req: Request): Promise { - return PenguenoRequest.from(req) - .bimap(TraceUtil.withClassTrace(this)) - .map((req) => this.route(req)) - .get(); - } } diff --git a/server/health.ts b/server/health.ts index 8435865..6a1f77f 100644 --- a/server/health.ts +++ b/server/health.ts @@ -14,7 +14,7 @@ export const healthCheck: HealthChecker = ( input: ITraceable, ): Promise> => input - .bimap(TraceUtil.withFunctionTrace(healthCheck)) + .flatMap(TraceUtil.withFunctionTrace(healthCheck)) .move(getRequiredEnv('LAMINAR_HOST')) // ensure LAMINAR_HOST is propagated to getStdout for other procedures .map((tEitherEnv) => diff --git a/server/hono_proxy.ts b/server/hono_proxy.ts new file mode 100644 index 0000000..f729819 --- /dev/null +++ b/server/hono_proxy.ts @@ -0,0 +1,71 @@ +import { + BaseRequest, + Either, + IEither, + LogMetricTraceable, + Metric, + PenguenoRequest, + Server, + Signals, + TraceUtil, +} from '@emprespresso/pengueno'; + +import { serve, ServerType } from '@hono/node-server'; +import { Hono } from 'hono'; + +const AppLifetimeMetric = Metric.fromName('HonoAppLifetime').asResult(); +const AppRequestMetric = Metric.fromName('HonoAppRequest'); + +export class HonoProxy { + private readonly app = LogMetricTraceable.of(new Hono()) + .flatMap(TraceUtil.withTrace(`AppId = ${crypto.randomUUID()}`)) + .flatMap(TraceUtil.withMetricTrace(AppLifetimeMetric)); + + constructor(private readonly server: Server) {} + + public async serve(port: number, hostname: string): Promise> { + return this.app + .map((tApp) => + Either.fromFailable(() => { + const app = tApp.get(); + app.all('*', async (c) => + tApp + .flatMap(TraceUtil.withMetricTrace(AppRequestMetric)) + .move(c.req) + .flatMap((tRequest) => PenguenoRequest.from(tRequest)) + .map((req) => this.server.serve(req)) + .map( + TraceUtil.promiseify((tResponse) => { + tResponse.trace.trace(AppRequestMetric.count.withValue(1.0)); + return new Response(tResponse.get().body(), tResponse.get()); + }), + ) + .get(), + ); + return serve({ + fetch: (_r) => app.fetch(_r), + port, + hostname, + }); + }), + ) + .peek(TraceUtil.traceResultingEither()) + .peek((tServe) => + tServe + .get() + .mapRight(() => + tServe.trace.trace( + `haii im still listening at http://${hostname}:${port} ~uwu dont think i forgot`, + ), + ), + ) + .map((tEitherServer) => + tEitherServer + .get() + .mapRight((server) => tEitherServer.move(server)) + .flatMapAsync((tServer) => Signals.awaitClose(tServer)), + ) + .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(AppLifetimeMetric))) + .get(); + } +} diff --git a/server/index.ts b/server/index.ts index c33b43e..d018a4e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,31 +1,13 @@ #!/usr/bin/env node -export * from './job'; -export * from './ci'; -export * from './health'; +export * from './job/index.js'; +export * from './ci.js'; +export * from './health.js'; +export * from './hono_proxy.js'; -import { CiHookServer } from '.'; -import { Either, type IEither } from '@emprespresso/pengueno'; -import { serve } from '@hono/node-server'; -import { Hono } from 'hono'; +import { CiHookServer, HonoProxy } from '@emprespresso/ci_server'; const server = new CiHookServer(); +const hono = new HonoProxy(server); -const neverEndingPromise = new Promise>(() => {}); -export const runServer = (port: number, host: string): Promise> => - Either.fromFailable(() => { - const app = new Hono(); - - app.all('*', async (c) => { - const response = await server.serve(c.req.raw); - return response; - }); - - serve({ - fetch: app.fetch, - port, - hostname: host, - }); - - console.log(`server running on http://${host}:${port} :D`); - }).flatMapAsync(() => neverEndingPromise); +export const runServer = (port: number, hostname: string) => hono.serve(port, hostname); diff --git a/server/job/index.ts b/server/job/index.ts index ecf0984..92c4682 100644 --- a/server/job/index.ts +++ b/server/job/index.ts @@ -1,2 +1,2 @@ -export * from './queue'; -export * from './run_activity'; +export * from './queue.js'; +export * from './run_activity.js'; diff --git a/server/job/queue.ts b/server/job/queue.ts index 2392222..4b21186 100644 --- a/server/job/queue.ts +++ b/server/job/queue.ts @@ -2,13 +2,13 @@ import { getStdout, type Mapper, memoize, - Either, type IEither, type ITraceable, LogLevel, Metric, type ServerTrace, TraceUtil, + PenguenoError, } from '@emprespresso/pengueno'; import { type Job } from '@emprespresso/ci_model'; @@ -23,7 +23,7 @@ export class LaminarJobQueuer implements IJobQueuer private static GetJobTypeTrace = (jobType: string) => `LaminarJobQueue.Queue.${jobType}`; private static JobTypeMetrics = memoize((jobType: string) => - Metric.fromName(LaminarJobQueuer.GetJobTypeTrace(jobType)), + Metric.fromName(LaminarJobQueuer.GetJobTypeTrace(jobType)).asResult(), ); public queue(j: ITraceable) { @@ -32,8 +32,8 @@ export class LaminarJobQueuer implements IJobQueuer const metric = LaminarJobQueuer.JobTypeMetrics(jobType); return j - .bimap(TraceUtil.withTrace(trace)) - .bimap(TraceUtil.withMetricTrace(metric)) + .flatMap(TraceUtil.withTrace(trace)) + .flatMap(TraceUtil.withMetricTrace(metric)) .map((j) => { const { type: jobType, arguments: args } = j.get(); const laminarCommand = [ @@ -47,26 +47,24 @@ export class LaminarJobQueuer implements IJobQueuer .peek((c) => c.trace.trace(`im so excited to see how this queue job will end!! (>ᴗ<): ${c.get().toString()}`), ) - .map(getStdout) - .peek( - TraceUtil.promiseify((q) => - q.trace.trace(q.get().fold(({ isLeft }) => (isLeft ? metric.failure : metric.success))), - ), - ) + .map((c) => getStdout(c)) + .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(metric))) .map( TraceUtil.promiseify((q) => - q.get().fold(({ isLeft, value }) => { - if (isLeft) { - q.trace.addTrace(LogLevel.ERROR).trace(value.toString()); - return Either.left(value); - } - q.trace.addTrace(LogLevel.DEBUG).trace(`stdout ${value}`); - const [jobName, jobId] = value.split(':'); - const jobUrl = `${this.queuePositionPrefix}/jobs/${jobName}/${jobId}`; + q.get().mapBoth( + (err) => { + q.trace.traceScope(LogLevel.ERROR).trace(err); + return err; + }, + (ok) => { + q.trace.traceScope(LogLevel.DEBUG).trace(`stdout ${ok}`); + const [jobName, jobId] = ok.split(':'); + const jobUrl = `${this.queuePositionPrefix}/jobs/${jobName}/${jobId}`; - q.trace.trace(`all queued up and weady to go~ (˘ω˘) => ${jobUrl}`); - return Either.right(jobUrl); - }), + q.trace.trace(`all queued up and weady to go~ (˘ω˘) => ${jobUrl}`); + return jobUrl; + }, + ), ), ) .get(); diff --git a/server/job/run_activity.ts b/server/job/run_activity.ts index 9f25cf8..22bc4c7 100644 --- a/server/job/run_activity.ts +++ b/server/job/run_activity.ts @@ -7,7 +7,9 @@ import { jsonModel, JsonResponse, LogLevel, + LogMetricTraceSupplier, Metric, + MetricsTraceSupplier, PenguenoError, type PenguenoRequest, type ServerTrace, @@ -15,39 +17,35 @@ import { validateExecutionEntries, } from '@emprespresso/pengueno'; import { isJob, type Job } from '@emprespresso/ci_model'; -import { IJobQueuer } from './queue'; +import { IJobQueuer } from './queue.js'; -const wellFormedJobMetric = Metric.fromName('Job.WellFormed'); +const wellFormedJobMetric = Metric.fromName('Job.WellFormed').asResult(); const jobJsonTransformer = (j: ITraceable): IEither => j - .bimap(TraceUtil.withMetricTrace(wellFormedJobMetric)) + .flatMap(TraceUtil.withMetricTrace(wellFormedJobMetric)) .map((tJson): IEither => { const tJob = tJson.get(); if (!isJob(tJob) || !validateExecutionEntries(tJob)) { const err = 'seems like a pwetty mawfomed job (-.-)'; - tJson.trace.addTrace(LogLevel.WARN).trace(err); + tJson.trace.traceScope(LogLevel.WARN).trace(err); return Either.left(new PenguenoError(err, 400)); } return Either.right(tJob); }) - .peek((tJob) => - tJob.trace.trace( - tJob.get().fold(({ isLeft }) => (isLeft ? wellFormedJobMetric.failure : wellFormedJobMetric.success)), - ), - ) + .peek(TraceUtil.traceResultingEither(wellFormedJobMetric)) .get(); export interface IJobHookActivity { processHook: IActivity; } -const jobHookRequestMetric = Metric.fromName('JobHook.process'); +const jobHookRequestMetric = Metric.fromName('JobHook.process').asResult(); export class JobHookActivityImpl implements IJobHookActivity { constructor(private readonly queuer: IJobQueuer>) {} private trace(r: ITraceable) { - return r.bimap(TraceUtil.withClassTrace(this)).bimap(TraceUtil.withMetricTrace(jobHookRequestMetric)); + return r.flatMap(TraceUtil.withClassTrace(this)).flatMap(TraceUtil.withMetricTrace(jobHookRequestMetric)); } public processHook(r: ITraceable) { @@ -63,29 +61,34 @@ export class JobHookActivityImpl implements IJobHookActivity { return eitherQueued.mapLeft((e) => new PenguenoError(e.message, 500)); }); }) + .flatMapAsync( + TraceUtil.promiseify((tEitherQueued) => { + const errorSource = tEitherQueued + .get() + .left() + .map(({ source }) => source) + .orSome(() => ErrorSource.SYSTEM) + .get(); + const shouldWarn = errorSource === ErrorSource.USER; + return TraceUtil.traceResultingEither( + jobHookRequestMetric, + shouldWarn, + )(tEitherQueued); + }), + ) .peek( TraceUtil.promiseify((tJob) => - tJob.get().fold(({ isRight, value }) => { - if (isRight) { - tJob.trace.trace(jobHookRequestMetric.success); - tJob.trace.trace(`all queued up and weady to go :D !! ${value}`); - return; - } - - tJob.trace.trace( - value.source === ErrorSource.SYSTEM - ? jobHookRequestMetric.failure - : jobHookRequestMetric.warn, - ); - tJob.trace.addTrace(value.source).trace(`${value}`); - }), + tJob.get().mapRight((job) => tJob.trace.trace(`all queued up and weady to go :D !! ${job}`)), ), ) .map( TraceUtil.promiseify( (tEitherQueuedJob) => new JsonResponse(r, tEitherQueuedJob.get(), { - status: tEitherQueuedJob.get().fold(({ isRight, value }) => (isRight ? 200 : value.status)), + status: tEitherQueuedJob.get().fold( + ({ status }) => status, + () => 200, + ), }), ), ) diff --git a/tsconfig.json b/tsconfig.json index 105c510..a28cdaf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,7 @@ "@emprespresso/pengueno": ["./u/index.ts"], "@emprespresso/ci_model": ["./model/index.ts"], "@emprespresso/ci_server": ["./server/index.ts"], - "@emprespresso/ci_worker": ["./worker/index.ts"], + "@emprespresso/ci_worker": ["./worker/index.ts"] } }, "include": ["**/*.ts", "**/*.js"], diff --git a/u/fn/callable.ts b/u/fn/callable.ts deleted file mode 100644 index 8a61057..0000000 --- a/u/fn/callable.ts +++ /dev/null @@ -1,20 +0,0 @@ -// deno-lint-ignore no-explicit-any -export interface Callable { - (...args: Array): T; -} - -export interface Supplier extends Callable { - (): T; -} - -export interface Mapper extends Callable { - (t: T): U; -} - -export interface BiMapper extends Callable { - (t: T, u: U): R; -} - -export interface SideEffect extends Mapper {} - -export interface BiSideEffect extends BiMapper {} diff --git a/u/fn/either.ts b/u/fn/either.ts deleted file mode 100644 index 8c47b64..0000000 --- a/u/fn/either.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { type Mapper, type Supplier, isObject } from '@emprespresso/pengueno'; - -type IEitherTag = 'IEither'; -const iEitherTag: IEitherTag = 'IEither'; - -export interface _Either { - readonly isLeft: LeftT; - readonly isRight: RightT; - readonly value: T; -} -export type Left = _Either; -export type Right = _Either; - -export interface IEither { - readonly _tag: IEitherTag; - - mapBoth: <_E, _T>(errBranch: Mapper, okBranch: Mapper) => IEither<_E, _T>; - fold: <_T>(folder: Mapper | Right, _T>) => _T; - moveRight: <_T>(t: _T) => IEither; - mapRight: <_T>(mapper: Mapper) => IEither; - mapLeft: <_E>(mapper: Mapper) => IEither<_E, T>; - flatMap: <_T>(mapper: Mapper>) => IEither; - flatMapAsync: <_T>(mapper: Mapper>>) => Promise>; -} - -export class Either implements IEither { - private readonly self: Left | Right; - - private constructor( - init: { err?: E; ok?: T }, - public readonly _tag: IEitherTag = iEitherTag, - ) { - this.self = | Right>{ - isLeft: 'err' in init, - isRight: 'ok' in init, - value: init.err ?? init.ok!, - }; - } - - public moveRight<_T>(t: _T) { - return this.mapRight(() => t); - } - - public fold<_T>(folder: Mapper | Right, _T>): _T { - return folder(this.self); - } - - public mapBoth<_E, _T>(errBranch: Mapper, okBranch: Mapper): IEither<_E, _T> { - if (this.self.isLeft) return Either.left(errBranch(this.self.value)); - return Either.right(okBranch(this.self.value)); - } - - public flatMap<_T>(mapper: Mapper>): IEither { - if (this.self.isRight) return mapper(this.self.value); - return Either.left(this.self.value); - } - - public mapRight<_T>(mapper: Mapper): IEither { - if (this.self.isRight) return Either.right(mapper(this.self.value)); - return Either.left(this.self.value); - } - - public mapLeft<_E>(mapper: Mapper): IEither<_E, T> { - if (this.self.isLeft) return Either.left<_E, T>(mapper(this.self.value)); - return Either.right<_E, T>(this.self.value); - } - - public async flatMapAsync<_T>(mapper: Mapper>>): Promise> { - if (this.self.isLeft) { - return Promise.resolve(Either.left(this.self.value)); - } - return await mapper(this.self.value).catch((err) => Either.left(err)); - } - - static left(e: E): IEither { - return new Either({ err: e }); - } - - static right(t: T): IEither { - return new Either({ ok: t }); - } - - static fromFailable(s: Supplier): IEither { - try { - return Either.right(s()); - } catch (e) { - return Either.left(e as E); - } - } - - static async fromFailableAsync(s: Supplier> | Promise): Promise> { - return await (typeof s === 'function' ? s() : s) - .then((t: T) => Either.right(t)) - .catch((e: E) => Either.left(e)); - } -} - -export const isEither = (o: unknown): o is IEither => { - return isObject(o) && '_tag' in o && o._tag === 'IEither'; -}; diff --git a/u/fn/index.ts b/u/fn/index.ts deleted file mode 100644 index 1ec71aa..0000000 --- a/u/fn/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './callable.js'; -export * from './either.js'; diff --git a/u/history.ts b/u/history.ts deleted file mode 100644 index 5b13961..0000000 --- a/u/history.ts +++ /dev/null @@ -1,36 +0,0 @@ -export interface History { - undo: () => History | undefined; - redo: () => History | undefined; - - get: () => T; - add: (value: T) => History; -} - -export class HistoryImpl implements History { - private readonly item: T; - private previous?: History; - private next?: History; - - constructor(item: T) { - this.item = item; - } - - public get(): T { - return this.item; - } - - public undo(): History | undefined { - return this.previous; - } - - public redo(): History | undefined { - return this.next; - } - - public add(value: T): History { - const newHistory = new HistoryImpl(value); - newHistory.previous = this; - this.next = newHistory; - return newHistory; - } -} diff --git a/u/index.ts b/u/index.ts index 0c8c760..e2e0768 100644 --- a/u/index.ts +++ b/u/index.ts @@ -1,6 +1,5 @@ -export * from './fn/index.js'; export * from './leftpadesque/index.js'; -export * from './process/index.js'; export * from './trace/index.js'; +export * from './process/index.js'; export * from './server/index.js'; -export * from './history.js'; +export * from './types/index.js'; diff --git a/u/leftpadesque/index.ts b/u/leftpadesque/index.ts index 6403e4a..09a0bd1 100644 --- a/u/leftpadesque/index.ts +++ b/u/leftpadesque/index.ts @@ -1,4 +1,3 @@ -export * from './object.js'; export * from './prepend.js'; export * from './debug.js'; export * from './memoize.js'; diff --git a/u/leftpadesque/object.ts b/u/leftpadesque/object.ts deleted file mode 100644 index fe97999..0000000 --- a/u/leftpadesque/object.ts +++ /dev/null @@ -1 +0,0 @@ -export const isObject = (o: unknown): o is object => typeof o === 'object' && !Array.isArray(o) && !!o; diff --git a/u/process/argv.ts b/u/process/argv.ts index dcdba85..dca5098 100644 --- a/u/process/argv.ts +++ b/u/process/argv.ts @@ -1,4 +1,4 @@ -import { Either, type Mapper, type IEither } from '@emprespresso/pengueno'; +import { Either, type Mapper, type IEither, Optional } from '@emprespresso/pengueno'; export const isArgKey = (k: string): k is K => k.startsWith('--'); @@ -13,22 +13,27 @@ export const getArg = ( argv: Array, whenValue: ArgHandler, ): IEither => { - const value = - argv - .filter((_argv) => isArgKey(_argv) && _argv.split('=')[0] === arg) - .map((_argv, i) => { - const next = _argv.includes('=') ? _argv.split('=')[1] : argv.at(i + 1); - if (next) { - if (isArgKey(next)) return whenValue.unspecified; - return whenValue.present(next); - } - return whenValue.unspecified; - }) - .find((x) => x) ?? whenValue.absent; - if (value === undefined) { - return Either.left(new Error('no value specified for ' + arg)); + 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(v)) + .orSome(() => + Either.left( + new Error(`arg ${arg} is not present in arguments list and does not have an 'absent' value`), + ), + ) + .get(); } - return Either.right(value); + + return argIndex + .flatMap((idx) => + Optional.from(argv.at(idx)).map((_argv) => (_argv.includes('=') ? _argv.split('=')[1] : argv.at(idx + 1))), + ) + .map((next) => (isArgKey(next) ? whenValue.unspecified : whenValue.present(next))) + .map((v) => Either.right(v)) + .get(); }; type MappedArgs< @@ -55,10 +60,10 @@ export const argv = < return getArg(arg, argv, handler).mapRight((value) => [arg, value] as const); }; - return args + const res = args .map(processArg) .reduce( - (acc: IEither>, current: IEither) => + (acc: IEither>, current: IEither) => acc.flatMap((accValue) => current.mapRight(([key, value]) => ({ ...accValue, @@ -68,4 +73,5 @@ export const argv = < Either.right(>{}), ) .mapRight((result) => result); + return res; }; diff --git a/u/process/env.ts b/u/process/env.ts index 1e4fd32..9a55488 100644 --- a/u/process/env.ts +++ b/u/process/env.ts @@ -1,10 +1,11 @@ -import { Either, type IEither } from '@emprespresso/pengueno'; +import { IOptional, Either, Optional, type IEither } from '@emprespresso/pengueno'; -export const getRequiredEnv = (name: V): IEither => - Either.fromFailable(() => process.env[name] as V | undefined) // could throw when no permission. - .flatMap( - (v) => (v && Either.right(v)) || Either.left(new Error(`environment variable "${name}" is required D:`)), - ); +export const getEnv = (name: string): IOptional => Optional.from(process.env[name]); + +export const getRequiredEnv = (name: string): IEither => + Either.fromFailable(() => getEnv(name).get()).mapLeft( + () => new Error(`environment variable "${name}" is required D:`), + ); type ObjectFromList, V = string> = { [K in T extends ReadonlyArray ? U : never]: V; diff --git a/u/process/index.ts b/u/process/index.ts index 4ffbf2a..6945a0f 100644 --- a/u/process/index.ts +++ b/u/process/index.ts @@ -2,3 +2,4 @@ export * from './env.js'; export * from './run.js'; export * from './validate_identifier.js'; export * from './argv.js'; +export * from './signals.js'; diff --git a/u/process/run.ts b/u/process/run.ts index e3c4c3d..1d19129 100644 --- a/u/process/run.ts +++ b/u/process/run.ts @@ -1,9 +1,10 @@ import { Either, - type IEither, + IEither, type ITraceable, LogLevel, - type LogTraceSupplier, + LogMetricTraceSupplier, + Metric, TraceUtil, } from '@emprespresso/pengueno'; import { promisify } from 'node:util'; @@ -13,34 +14,27 @@ const exec = promisify(execCallback); export type Command = string[] | string; export type StdStreams = { stdout: string; stderr: string }; +export const CmdMetric = Metric.fromName('Exec').asResult(); export const getStdout = ( - c: ITraceable, + c: ITraceable, options: { env?: Record; clearEnv?: boolean } = {}, ): Promise> => c - .bimap(TraceUtil.withFunctionTrace(getStdout)) - .bimap((tCmd) => { + .flatMap(TraceUtil.withFunctionTrace(getStdout)) + .flatMap((tCmd) => tCmd.traceScope(() => `Command = ${tCmd.get()}`)) + .map((tCmd) => { const cmd = tCmd.get(); - tCmd.trace.trace(`Command = ${cmd} :> im gonna run this command! `); - const _exec = typeof cmd === 'string' ? cmd : cmd.join(' '); const env = options.clearEnv ? options.env : { ...process.env, ...options.env }; - - const p: Promise> = Either.fromFailableAsync(exec(_exec, { env })); - return [p, `Command = ${_exec}`]; + return Either.fromFailableAsync(exec(_exec, { env })); }) .map( - TraceUtil.promiseify( - (tEitherProcess): IEither => - tEitherProcess.get().fold(({ isLeft, value }) => { - if (isLeft) { - return Either.left(value); - } - if (value.stderr) { - tEitherProcess.trace.addTrace(LogLevel.DEBUG).trace(`StdErr = ${value.stderr}`); - } - return Either.right(value.stdout); - }), + 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(); diff --git a/u/process/signals.ts b/u/process/signals.ts new file mode 100644 index 0000000..c4feb7a --- /dev/null +++ b/u/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 { + readonly close: SideEffect>; +} + +export class Signals { + public static async awaitClose( + t: ITraceable, LogMetricTraceSupplier>, + ): Promise> { + const success: IEither = Either.right(undefined); + return new Promise>((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(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/u/server/activity/health.ts b/u/server/activity/health.ts index b3ae559..9396490 100644 --- a/u/server/activity/health.ts +++ b/u/server/activity/health.ts @@ -23,7 +23,7 @@ export interface IHealthCheckActivity { checkHealth: IActivity; } -const healthCheckMetric: IMetric = Metric.fromName('Health'); +const healthCheckMetric = Metric.fromName('Health').asResult(); export interface HealthChecker extends Mapper, Promise>> {} export class HealthCheckActivityImpl implements IHealthCheckActivity { @@ -31,36 +31,18 @@ export class HealthCheckActivityImpl implements IHealthCheckActivity { public checkHealth(req: ITraceable) { return req - .bimap(TraceUtil.withFunctionTrace(this.checkHealth)) - .bimap(TraceUtil.withMetricTrace(healthCheckMetric)) + .flatMap(TraceUtil.withFunctionTrace(this.checkHealth)) + .flatMap(TraceUtil.withMetricTrace(healthCheckMetric)) .flatMap((r) => r.move(HealthCheckInput.CHECK).map((input) => this.check(input))) - .peek( - TraceUtil.promiseify((h) => - h.get().fold(({ isLeft, value }) => { - if (!isLeft) { - h.trace.trace(healthCheckMetric.success); - return; - } - h.trace.trace(healthCheckMetric.failure); - h.trace.addTrace(LogLevel.ERROR).trace(value); - }), - ), - ) + .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(healthCheckMetric))) .map( - TraceUtil.promiseify((h) => - h - .get() - .mapBoth( - () => 'oh no, i need to eat more vegetables (。•́︿•̀。)...', - () => 'think im healthy!! (✿˘◡˘) ready to do work~', - ) - .fold( - ({ isLeft, value: message }) => - new JsonResponse(req, message, { - status: isLeft ? 500 : 200, - }), - ), - ), + 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/u/server/filter/index.ts b/u/server/filter/index.ts index 62a584d..75168c7 100644 --- a/u/server/filter/index.ts +++ b/u/server/filter/index.ts @@ -27,7 +27,7 @@ export interface RequestFilter< Err extends PenguenoError = PenguenoError, RIn = ITraceable, > { - (req: RIn): Promise>; + (req: RIn): IEither | Promise>; } export * from './method.js'; diff --git a/u/server/filter/json.ts b/u/server/filter/json.ts index 527d483..bc53d47 100644 --- a/u/server/filter/json.ts +++ b/u/server/filter/json.ts @@ -15,30 +15,22 @@ export interface JsonTransformer { (json: ITraceable): IEither; } -const ParseJsonMetric = Metric.fromName('JsonParse'); +const ParseJsonMetric = Metric.fromName('JsonParse').asResult(); export const jsonModel = (jsonTransformer: JsonTransformer): RequestFilter => (r: ITraceable) => r - .bimap(TraceUtil.withFunctionTrace(jsonModel)) - .bimap(TraceUtil.withMetricTrace(ParseJsonMetric)) + .flatMap(TraceUtil.withFunctionTrace(jsonModel)) + .flatMap(TraceUtil.withMetricTrace(ParseJsonMetric)) .map((j) => - Either.fromFailableAsync(>j.get().json()).then((either) => + Either.fromFailableAsync(>j.get().req.json()).then((either) => either.mapLeft((errReason) => { - j.trace.addTrace(LogLevel.WARN).trace(errReason); + j.trace.traceScope(LogLevel.WARN).trace(errReason); return new PenguenoError('seems to be invalid JSON (>//<) can you fix?', 400); }), ), ) - .peek( - TraceUtil.promiseify((traceableEither) => - traceableEither - .get() - .fold(({ isLeft }) => - traceableEither.trace.trace(ParseJsonMetric[isLeft ? 'failure' : 'success']), - ), - ), - ) + .flatMapAsync(TraceUtil.promiseify(TraceUtil.traceResultingEither(ParseJsonMetric))) .map( TraceUtil.promiseify((traceableEitherJson) => traceableEitherJson diff --git a/u/server/filter/method.ts b/u/server/filter/method.ts index 5ca5716..7d6aa76 100644 --- a/u/server/filter/method.ts +++ b/u/server/filter/method.ts @@ -1,5 +1,7 @@ import { Either, + HttpMethod, + IEither, type ITraceable, LogLevel, PenguenoError, @@ -9,24 +11,20 @@ import { TraceUtil, } from '@emprespresso/pengueno'; -type HttpMethod = 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH'; - export const requireMethod = (methods: Array): RequestFilter => (req: ITraceable) => req - .bimap(TraceUtil.withFunctionTrace(requireMethod)) - .move(Promise.resolve(req.get())) - .map( - TraceUtil.promiseify((t) => { - const { method: _method } = t.get(); - const method = _method; - if (!methods.includes(method)) { - const msg = "that's not how you pet me (⋟﹏⋞)~"; - t.trace.addTrace(LogLevel.WARN).trace(msg); - return Either.left(new PenguenoError(msg, 405)); - } - return Either.right(method); - }), - ) + .flatMap(TraceUtil.withFunctionTrace(requireMethod)) + .map((t): IEither => { + 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/u/server/http/body.ts b/u/server/http/body.ts new file mode 100644 index 0000000..5fc4caa --- /dev/null +++ b/u/server/http/body.ts @@ -0,0 +1,10 @@ +export type Body = + | ArrayBuffer + | AsyncIterable + | Blob + | FormData + | Iterable + | NodeJS.ArrayBufferView + | URLSearchParams + | null + | string; diff --git a/u/server/http/index.ts b/u/server/http/index.ts new file mode 100644 index 0000000..ef1c039 --- /dev/null +++ b/u/server/http/index.ts @@ -0,0 +1,3 @@ +export * from './body.js'; +export * from './status.js'; +export * from './method.js'; diff --git a/u/server/http/method.ts b/u/server/http/method.ts new file mode 100644 index 0000000..172d77a --- /dev/null +++ b/u/server/http/method.ts @@ -0,0 +1 @@ +export type HttpMethod = 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH'; diff --git a/u/server/http/status.ts b/u/server/http/status.ts new file mode 100644 index 0000000..15cb30c --- /dev/null +++ b/u/server/http/status.ts @@ -0,0 +1,71 @@ +export const HttpStatusCodes: Record = { + 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/u/server/index.ts b/u/server/index.ts index 17cbbdf..1cefb71 100644 --- a/u/server/index.ts +++ b/u/server/index.ts @@ -1,7 +1,13 @@ -import type { LogMetricTraceSupplier } from '@emprespresso/pengueno'; +import type { ITraceable, LogMetricTraceSupplier, Mapper } from '@emprespresso/pengueno'; export type ServerTrace = LogMetricTraceSupplier; +export * from './http/index.js'; +export * from './response/index.js'; +export * from './request/index.js'; export * from './activity/index.js'; export * from './filter/index.js'; -export * from './response.js'; -export * from './request.js'; + +import { PenguenoRequest, PenguenoResponse } from '@emprespresso/pengueno'; +export interface Server { + readonly serve: Mapper, Promise>; +} diff --git a/u/server/request.ts b/u/server/request.ts deleted file mode 100644 index 10610f1..0000000 --- a/u/server/request.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { TraceUtil, LogMetricTraceable, LogTraceable } 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: Request, - public readonly id: string, - public readonly at: Date, - ) { - super(_input); - } - - public baseResponseHeaders(): Record { - 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 { - const id = crypto.randomUUID(); - const url = new URL(request.url); - const { pathname } = url; - const logTraceable = LogTraceable.of(new PenguenoRequest(request, id, new Date())).bimap( - TraceUtil.withTrace(`RequestId = ${id}, Method = ${request.method}, Path = ${pathname}`), - ); - return LogMetricTraceable.ofLogTraceable(logTraceable); - } -} diff --git a/u/server/request/index.ts b/u/server/request/index.ts new file mode 100644 index 0000000..41d59b7 --- /dev/null +++ b/u/server/request/index.ts @@ -0,0 +1,18 @@ +import { HttpMethod } from '@emprespresso/pengueno'; + +export interface BaseRequest { + url: string; + method: HttpMethod; + + header(): Record; + + formData(): Promise; + json(): Promise; + text(): Promise; + + param(key: string): string | undefined; + query(): Record; + queries(): Record; +} + +export * from './pengueno.js'; diff --git a/u/server/request/pengueno.ts b/u/server/request/pengueno.ts new file mode 100644 index 0000000..31563e9 --- /dev/null +++ b/u/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 { + 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): ITraceable { + 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/u/server/response.ts b/u/server/response.ts deleted file mode 100644 index 18d70b5..0000000 --- a/u/server/response.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - type IEither, - isEither, - type ITraceable, - Metric, - type PenguenoRequest, - type ServerTrace, -} from '@emprespresso/pengueno'; - -export type BodyInit = - | ArrayBuffer - | AsyncIterable - | Blob - | FormData - | Iterable - | NodeJS.ArrayBufferView - | URLSearchParams - | null - | string; -export type ResponseBody = object | string; -export type TResponseInit = Omit & { - status: number; - headers?: Record; -}; - -const getResponse = (req: PenguenoRequest, opts: TResponseInit): ResponseInit => { - const baseHeaders = req.baseResponseHeaders(); - const optHeaders = opts.headers || {}; - - return { - ...opts, - headers: { - ...baseHeaders, - ...optHeaders, - 'Content-Type': (optHeaders['Content-Type'] ?? 'text/plain') + '; charset=utf-8', - } as Record, - }; -}; - -const ResponseCodeMetrics = [0, 1, 2, 3, 4, 5].map((x) => Metric.fromName(`response.${x}xx`)); -export const getResponseMetrics = (status: number) => { - const index = Math.floor(status / 100); - return ResponseCodeMetrics.map((metric, i) => metric.count.withValue(i === index ? 1.0 : 0.0)); -}; - -export class PenguenoResponse extends Response { - constructor(req: ITraceable, msg: BodyInit, opts: TResponseInit) { - const responseOpts = getResponse(req.get(), opts); - for (const metric of getResponseMetrics(opts.status)) { - req.trace.trace(metric); - } - super(msg, responseOpts); - } -} - -export class JsonResponse extends PenguenoResponse { - constructor( - req: ITraceable, - e: BodyInit | IEither, - opts: TResponseInit, - ) { - const optsWithJsonContentType: TResponseInit = { - ...opts, - headers: { - ...opts.headers, - 'Content-Type': 'application/json', - }, - }; - if (isEither(e)) { - super( - req, - JSON.stringify(e.fold(({ isLeft, value }) => (isLeft ? { error: value } : { ok: value }))), - optsWithJsonContentType, - ); - return; - } - super( - req, - JSON.stringify(Math.floor(opts.status / 100) > 4 ? { error: e } : { ok: e }), - optsWithJsonContentType, - ); - } -} diff --git a/u/server/response/index.ts b/u/server/response/index.ts new file mode 100644 index 0000000..17a2d97 --- /dev/null +++ b/u/server/response/index.ts @@ -0,0 +1,18 @@ +import { Body } from '@emprespresso/pengueno'; + +export interface BaseResponse { + status: number; + statusText: string; + headers: Record; + + body(): Body; +} + +export interface ResponseOpts { + status: number; + statusText?: string; + headers?: Record; +} + +export * from './pengueno.js'; +export * from './json_pengueno.js'; diff --git a/u/server/response/json_pengueno.ts b/u/server/response/json_pengueno.ts new file mode 100644 index 0000000..d0b74a8 --- /dev/null +++ b/u/server/response/json_pengueno.ts @@ -0,0 +1,29 @@ +import { + isEither, + ITraceable, + PenguenoRequest, + PenguenoResponse, + ResponseOpts, + ServerTrace, +} from '@emprespresso/pengueno'; + +type Jsonable = any; +export class JsonResponse extends PenguenoResponse { + constructor(req: ITraceable, e: Jsonable, _opts: ResponseOpts) { + const opts = { ..._opts, headers: { ..._opts.headers, 'Content-Type': 'application/json' } }; + if (isEither(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/u/server/response/pengueno.ts b/u/server/response/pengueno.ts new file mode 100644 index 0000000..5a953db --- /dev/null +++ b/u/server/response/pengueno.ts @@ -0,0 +1,59 @@ +import { + BaseResponse, + Body, + HttpStatusCodes, + ITraceable, + Metric, + Optional, + PenguenoRequest, + ResponseOpts, + ServerTrace, +} from '@emprespresso/pengueno'; + +const getHeaders = (req: PenguenoRequest, extraHeaders: Record) => { + 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; + + constructor( + req: ITraceable, + 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; + } +} diff --git a/u/trace/index.ts b/u/trace/index.ts index 18da87a..332fb52 100644 --- a/u/trace/index.ts +++ b/u/trace/index.ts @@ -1,5 +1,5 @@ export * from './itrace.js'; -export * from './util.js'; -export * from './logger.js'; -export * from './metrics.js'; +export * from './metric/index.js'; +export * from './log/index.js'; export * from './trace.js'; +export * from './util.js'; diff --git a/u/trace/itrace.ts b/u/trace/itrace.ts index 8cf123a..9c33ad2 100644 --- a/u/trace/itrace.ts +++ b/u/trace/itrace.ts @@ -1,69 +1,90 @@ import type { Mapper, SideEffect, Supplier } from '@emprespresso/pengueno'; -// the "thing" every Trace writer must "trace()" +/** + * the "thing" every Trace writer must "trace()". + */ type BaseTraceWith = string; export type ITraceWith = BaseTraceWith | T; export interface ITrace { - addTrace: Mapper, ITrace>; + /** + * creates a new trace scope which inherits from this trace. + */ + traceScope: Mapper, ITrace>; + + /** + * does the tracing. + */ trace: SideEffect>; } -export type ITraceableTuple = [T, BaseTraceWith | TraceWith]; -export type ITraceableMapper> = (w: W) => _T; +export type ITraceableTuple = { item: T; trace: BaseTraceWith | TraceWith }; +export type ITraceableMapper = (w: ITraceable) => _T; export interface ITraceable { readonly trace: ITrace; - get: Supplier; - move: <_T>(t: _T) => ITraceable<_T, Trace>; - map: <_T>(mapper: ITraceableMapper) => ITraceable<_T, Trace>; - bimap: <_T>(mapper: ITraceableMapper | Trace>, Trace>) => ITraceable<_T, Trace>; - peek: (peek: ITraceableMapper) => ITraceable; - flatMap: <_T>(mapper: ITraceableMapper, Trace>) => ITraceable<_T, Trace>; - flatMapAsync<_T>( + readonly get: Supplier; + + readonly move: <_T>(t: _T) => ITraceable<_T, Trace>; + readonly map: <_T>(mapper: ITraceableMapper) => ITraceable<_T, Trace>; + readonly bimap: <_T>(mapper: ITraceableMapper, Trace>) => ITraceable<_T, Trace>; + readonly coExtend: <_T>( + mapper: ITraceableMapper, Trace>, + ) => ReadonlyArray>; + readonly peek: (peek: ITraceableMapper) => ITraceable; + + readonly traceScope: (mapper: ITraceableMapper) => ITraceable; + + readonly flatMap: <_T>(mapper: ITraceableMapper, Trace>) => ITraceable<_T, Trace>; + readonly flatMapAsync: <_T>( mapper: ITraceableMapper>, Trace>, - ): ITraceable, Trace>; + ) => ITraceable, Trace>; } -export class TraceableImpl implements ITraceable { +export class TraceableImpl implements ITraceable { protected constructor( private readonly item: T, - public readonly trace: ITrace, + public readonly trace: ITrace, ) {} - public map<_T>(mapper: ITraceableMapper) { + public map<_T>(mapper: ITraceableMapper) { const result = mapper(this); return new TraceableImpl(result, this.trace); } - public flatMap<_T>(mapper: ITraceableMapper, TraceWith>): ITraceable<_T, TraceWith> { + public coExtend<_T>(mapper: ITraceableMapper, Trace>): ReadonlyArray> { + const results = mapper(this); + return Array.from(results).map((result) => this.move(result)); + } + + public flatMap<_T>(mapper: ITraceableMapper, Trace>): ITraceable<_T, Trace> { return mapper(this); } public flatMapAsync<_T>( - mapper: ITraceableMapper>, TraceWith>, - ): ITraceable, TraceWith> { + mapper: ITraceableMapper>, Trace>, + ): ITraceable, Trace> { return new TraceableImpl( mapper(this).then((t) => t.get()), this.trace, ); } - public peek(peek: ITraceableMapper) { + public traceScope(mapper: ITraceableMapper): ITraceable { + return new TraceableImpl(this.get(), this.trace.traceScope(mapper(this))); + } + + public peek(peek: ITraceableMapper) { peek(this); return this; } - public move<_T>(t: _T): ITraceable<_T, TraceWith> { + public move<_T>(t: _T): ITraceable<_T, Trace> { return this.map(() => t); } - public bimap<_T>(mapper: ITraceableMapper | 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 bimap<_T>(mapper: ITraceableMapper, Trace>) { + const { item, trace: _trace } = mapper(this); + return this.move(item).traceScope(() => _trace); } public get() { diff --git a/u/trace/log/ansi.ts b/u/trace/log/ansi.ts new file mode 100644 index 0000000..7ff16a3 --- /dev/null +++ b/u/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/u/trace/log/index.ts b/u/trace/log/index.ts new file mode 100644 index 0000000..670e333 --- /dev/null +++ b/u/trace/log/index.ts @@ -0,0 +1,5 @@ +export * from './ansi.js'; +export * from './level.js'; +export * from './logger.js'; +export * from './pretty_json_console.js'; +export * from './trace.js'; diff --git a/u/trace/log/level.ts b/u/trace/log/level.ts new file mode 100644 index 0000000..027dd71 --- /dev/null +++ b/u/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.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/u/trace/log/logger.ts b/u/trace/log/logger.ts new file mode 100644 index 0000000..3ced60a --- /dev/null +++ b/u/trace/log/logger.ts @@ -0,0 +1,5 @@ +import { LogLevel } from './level.js'; + +export interface ILogger { + readonly log: (level: LogLevel, ...args: string[]) => void; +} diff --git a/u/trace/log/pretty_json_console.ts b/u/trace/log/pretty_json_console.ts new file mode 100644 index 0000000..758af51 --- /dev/null +++ b/u/trace/log/pretty_json_console.ts @@ -0,0 +1,39 @@ +import { ANSI, LogLevel, ILogger } from './index.js'; + +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/u/trace/log/trace.ts b/u/trace/log/trace.ts new file mode 100644 index 0000000..3f9f1b2 --- /dev/null +++ b/u/trace/log/trace.ts @@ -0,0 +1,60 @@ +import { isDebug, ITrace, ITraceWith, memoize, Supplier } from '@emprespresso/pengueno'; +import { ILogger, isLogLevel, LogLevel, logLevelOrder, PrettyJsonConsoleLogger } from './index.js'; + +export type LogTraceSupplier = ITraceWith> | ITraceWith; + +export class LogTrace implements ITrace { + constructor( + private readonly logger: ILogger = new PrettyJsonConsoleLogger(), + private readonly traces: Array = [defaultTrace], + private readonly defaultLevel: LogLevel = LogLevel.INFO, + private readonly allowedLevels: Supplier> = defaultAllowedLevelsSupplier, + ) {} + + public traceScope(trace: LogTraceSupplier): ITrace { + 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) { + 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/u/trace/logger.ts b/u/trace/logger.ts deleted file mode 100644 index 91432fe..0000000 --- a/u/trace/logger.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { isDebug, type ITrace, type ITraceWith, type Supplier } from '@emprespresso/pengueno'; - -export type LogTraceSupplier = ITraceWith | Error>; -const defaultTrace = () => `TimeStamp = ${new Date().toISOString()}`; -export class LogTrace implements ITrace { - constructor( - private readonly logger: ILogger = new LoggerImpl(), - private readonly traces: Array = [defaultTrace], - private readonly defaultLevel: LogLevel = LogLevel.INFO, - private readonly allowedLevels: Supplier> = defaultAllowedLevels, - ) {} - - public addTrace(trace: LogTraceSupplier): ITrace { - 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().includes(_level)) return; - - const level = _level === LogLevel.UNKNOWN ? this.defaultLevel : _level; - this.logger.log(level, ...traces); - } - - private foldTraces(_traces: Array) { - 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, - }; - } -} - -export enum LogLevel { - UNKNOWN = 'UNKNOWN', - INFO = 'INFO', - WARN = 'WARN', - DEBUG = 'DEBUG', - ERROR = 'ERROR', - SYS = 'SYS', -} -const logLevelOrder: Array = [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); - -const defaultAllowedLevels = () => - [ - LogLevel.UNKNOWN, - ...(isDebug() ? [LogLevel.DEBUG] : []), - LogLevel.INFO, - LogLevel.WARN, - LogLevel.ERROR, - LogLevel.SYS, - ] as Array; - -export interface ILogger { - readonly log: (level: LogLevel, ...args: string[]) => void; -} -class LoggerImpl implements ILogger { - private readonly textEncoder = new TextEncoder(); - - 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)(this.textEncoder.encode(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}`; - } - } -} - -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/u/trace/metric/emittable.ts b/u/trace/metric/emittable.ts new file mode 100644 index 0000000..f3441ec --- /dev/null +++ b/u/trace/metric/emittable.ts @@ -0,0 +1,18 @@ +import { IEmittableMetric, MetricValue, MetricValueTag, Unit } from './index.js'; + +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/u/trace/metric/index.ts b/u/trace/metric/index.ts new file mode 100644 index 0000000..72c37d2 --- /dev/null +++ b/u/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 { + readonly name: string; + readonly unit: Unit; + readonly value: number; + readonly emissionTimestamp: number; +} + +export interface IEmittableMetric { + readonly name: string; + readonly unit: Unit; + readonly withValue: Mapper; +} + +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 { + 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.js'; +export * from './metric.js'; +export * from './trace.js'; diff --git a/u/trace/metric/metric.ts b/u/trace/metric/metric.ts new file mode 100644 index 0000000..8ef339f --- /dev/null +++ b/u/trace/metric/metric.ts @@ -0,0 +1,54 @@ +import { EmittableMetric, IMetric, IMetricTag, IResultMetric, Unit } from './index.js'; + +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) { + 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/u/trace/metric/trace.ts b/u/trace/metric/trace.ts new file mode 100644 index 0000000..0c5fe37 --- /dev/null +++ b/u/trace/metric/trace.ts @@ -0,0 +1,59 @@ +import { IMetric, isIMetric, isMetricValue, ITrace, ITraceWith, MetricValue, SideEffect } from '@emprespresso/pengueno'; + +export type MetricsTraceSupplier = + | ITraceWith + | Array>; +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 { + constructor( + private readonly metricConsumer: SideEffect>, + private readonly activeTraces: ReadonlyMap = new Map(), + private readonly completedTraces: ReadonlySet = 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/u/trace/metrics.ts b/u/trace/metrics.ts deleted file mode 100644 index 2301afd..0000000 --- a/u/trace/metrics.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - isObject, - type ITrace, - type ITraceWith, - type Mapper, - type SideEffect, - type Supplier, -} from '@emprespresso/pengueno'; - -export enum Unit { - COUNT = 'COUNT', - MILLISECONDS = 'MILLISECONDS', -} - -export interface IMetric { - readonly count: IEmittableMetric; - readonly time: IEmittableMetric; - readonly failure: undefined | IMetric; - readonly success: undefined | IMetric; - readonly warn: undefined | IMetric; - readonly children: Supplier>; - - 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; -} - -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: undefined | Metric = undefined, - public readonly success: undefined | Metric = undefined, - public readonly warn: undefined | Metric = undefined, - public readonly _tag: 'IMetric' = 'IMetric', - ) {} - - public children() { - return [this.failure, this.success, this.warn].filter((x) => x) as IMetric[]; - } - - static fromName(name: string, addChildren = true): Metric { - return new Metric( - new EmittableMetric(`${name}.count`, Unit.COUNT), - new EmittableMetric(`${name}.elapsed`, Unit.MILLISECONDS), - addChildren ? Metric.fromName(`${name}.failure`, false) : undefined, - addChildren ? Metric.fromName(`${name}.success`, false) : undefined, - addChildren ? Metric.fromName(`${name}.warn`, false) : undefined, - ); - } -} - -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; -type MetricTracingTuple = [IMetric, Date]; -export class MetricsTrace implements ITrace { - constructor( - private readonly metricConsumer: SideEffect>, - private readonly tracing: Array = [], - private readonly flushed: Set = new Set(), - ) {} - - public addTrace(trace: MetricsTraceSupplier) { - if (!isIMetric(trace)) return this; - return new MetricsTrace(this.metricConsumer)._nowTracing(trace); - } - - public trace(metric: MetricsTraceSupplier) { - if (typeof metric === 'undefined' || 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 { - 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 { - if (!metric) return this; - this.tracing.push([metric, new Date()]); - return this; - } -} diff --git a/u/trace/trace.ts b/u/trace/trace.ts index acc116f..e316ca8 100644 --- a/u/trace/trace.ts +++ b/u/trace/trace.ts @@ -10,7 +10,7 @@ import { type MetricsTraceSupplier, type MetricValue, TraceableImpl, -} from '@emprespresso/pengueno'; +} from './index.js'; export class LogTraceable extends TraceableImpl { public static LogTrace = new LogTrace(); @@ -19,8 +19,10 @@ export class LogTraceable extends TraceableImpl { } } -const getEmbeddedMetricConsumer = (logTrace: ITrace) => (metrics: Array) => - logTrace.addTrace(LogLevel.SYS).trace(`Metrics = ${JSON.stringify(metrics)}`); +const getEmbeddedMetricConsumer = (logTrace: ITrace) => (metrics: Array) => { + if (metrics.length === 0) return; + logTrace.traceScope(LogLevel.SYS).trace(`Metrics = ${JSON.stringify(metrics)}`); +}; export class EmbeddedMetricsTraceable extends TraceableImpl { public static MetricsTrace = new MetricsTrace(getEmbeddedMetricConsumer(LogTraceable.LogTrace)); @@ -37,12 +39,12 @@ export class LogMetricTrace implements ITrace { private metricsTrace: ITrace, ) {} - public addTrace(trace: LogTraceSupplier | MetricsTraceSupplier): LogMetricTrace { + public traceScope(trace: LogTraceSupplier | MetricsTraceSupplier): LogMetricTrace { if (isMetricsTraceSupplier(trace)) { - this.metricsTrace = this.metricsTrace.addTrace(trace); + this.metricsTrace = this.metricsTrace.traceScope(trace); return this; } - this.logTrace = this.logTrace.addTrace(trace); + this.logTrace = this.logTrace.traceScope(trace); return this; } diff --git a/u/trace/util.ts b/u/trace/util.ts index db1db63..ec67571 100644 --- a/u/trace/util.ts +++ b/u/trace/util.ts @@ -1,45 +1,59 @@ import { - ANSI, + IEither, + IMetric, + isEither, + ITraceable, + ITraceWith, + LogLevel, + ResultMetric, type Callable, - type IMetric, type ITraceableMapper, - type ITraceableTuple, - type MetricsTraceSupplier, } from '@emprespresso/pengueno'; export class TraceUtil { - static withTrace( - trace: string, - ansi?: Array, - ): ITraceableMapper>, Trace> { - if (ansi) { - return (t) => [t.get(), `${ansi.join('')}${trace}${ANSI.RESET}`]; - } - return (t) => [t.get(), trace]; + static promiseify( + mapper: ITraceableMapper, + ): ITraceableMapper, Promise, Trace> { + return (traceablePromise) => + traceablePromise.flatMapAsync(async (t) => t.move(await t.get()).map(mapper)).get(); } - static withMetricTrace( - metric: IMetric, - ): ITraceableMapper>, Trace> { - return (t) => [t.get(), metric as Trace]; + static traceResultingEither( + metric?: ResultMetric, + warnOnFailure = false, + ): ITraceableMapper, ITraceable, Trace>, Trace> { + return (t) => { + if (metric) + t.trace.trace( + t.get().fold( + (_err) => (warnOnFailure ? metric.warn : metric.failure), + (_ok) => metric.success, + ), + ); + return t.traceScope((_t) => + _t.get().fold( + (_err) => (warnOnFailure ? LogLevel.WARN : LogLevel.ERROR), + (_ok) => LogLevel.INFO, + ), + ); + }; } - static withFunctionTrace( - f: F, - ): ITraceableMapper>, Trace> { - return TraceUtil.withTrace(`fn.${f.name}`); + static withTrace>( + trace: _Trace, + ): ITraceableMapper, Trace> { + return (t) => t.traceScope(() => trace); } - static withClassTrace( - c: C, - ): ITraceableMapper>, Trace> { - return TraceUtil.withTrace(`class.${c.constructor.name}`); + static withMetricTrace(metric: IMetric): ITraceableMapper, Trace> { + return TraceUtil.withTrace(metric); } - static promiseify( - mapper: ITraceableMapper, - ): ITraceableMapper, Promise, Trace> { - return (traceablePromise) => - traceablePromise.flatMapAsync(async (t) => t.move(await t.get()).map(mapper)).get(); + static withFunctionTrace(f: F): ITraceableMapper, Trace> { + return TraceUtil.withTrace(`fn.${f.name}`); + } + + static withClassTrace(c: C): ITraceableMapper, Trace> { + return TraceUtil.withTrace(`class.${c.constructor.name}`); } } diff --git a/u/types/collections/cons.ts b/u/types/collections/cons.ts new file mode 100644 index 0000000..05dbe7c --- /dev/null +++ b/u/types/collections/cons.ts @@ -0,0 +1,40 @@ +import { IOptional, Mapper, Optional } from '@emprespresso/pengueno'; + +export interface ICons extends Iterable { + readonly value: T; + readonly next: IOptional>; + + readonly replace: Mapper>; + readonly before: Mapper>, ICons>; +} + +export class Cons implements ICons { + constructor( + public readonly value: T, + public readonly next: IOptional> = Optional.none(), + ) {} + + public before(head: IOptional>): ICons { + return new Cons(this.value, head); + } + + public replace(_value: T): ICons { + return new Cons(_value, this.next); + } + + *[Symbol.iterator]() { + for (let cur = Optional.some>(this); cur.present(); cur = cur.flatMap((cur) => cur.next)) { + yield cur.get().value; + } + } + + static addOnto(items: Iterable, tail: IOptional>): IOptional> { + return Array.from(items) + .reverse() + .reduce((cons, value) => Optional.from>(new Cons(value, cons)), tail); + } + + static from(items: Iterable): IOptional> { + return Cons.addOnto(items, Optional.none()); + } +} diff --git a/u/types/collections/index.ts b/u/types/collections/index.ts new file mode 100644 index 0000000..69e5d0b --- /dev/null +++ b/u/types/collections/index.ts @@ -0,0 +1,2 @@ +export * from './cons.js'; +export * from './list_zipper.js'; diff --git a/u/types/collections/list_zipper.ts b/u/types/collections/list_zipper.ts new file mode 100644 index 0000000..3df15b5 --- /dev/null +++ b/u/types/collections/list_zipper.ts @@ -0,0 +1,70 @@ +import { Cons, ICons } from './cons.js'; +import { IOptional, Mapper, Optional, Supplier } from '@emprespresso/pengueno'; + +export interface IZipper extends Iterable { + readonly read: Supplier>; + readonly next: Supplier>>; + readonly previous: Supplier>>; + + readonly prependChunk: Mapper, IZipper>; + readonly prepend: Mapper>; + readonly remove: Supplier>; + readonly replace: Mapper>; +} + +export class ListZipper implements IZipper { + private constructor( + private readonly reversedPathToHead: IOptional>, + private readonly currentHead: IOptional>, + ) {} + + public read(): IOptional { + return this.currentHead.map(({ value }) => value); + } + + public next(): IOptional> { + return this.currentHead.map>( + (head) => new ListZipper(Optional.some(head.before(this.reversedPathToHead)), head.next), + ); + } + + public previous(): IOptional> { + return this.reversedPathToHead.map>( + (lastVisited) => new ListZipper(lastVisited.next, Optional.some(lastVisited.before(this.currentHead))), + ); + } + + public prependChunk(values: Iterable): IZipper { + return new ListZipper(Cons.addOnto(Array.from(values).reverse(), this.reversedPathToHead), this.currentHead); + } + + public prepend(value: T): IZipper { + return this.prependChunk([value]); + } + + public remove(): IZipper { + const newHead = this.currentHead.flatMap((right) => right.next); + return new ListZipper(this.reversedPathToHead, newHead); + } + + public replace(value: T): IZipper { + const newHead = this.currentHead.map((right) => right.replace(value)); + return new ListZipper(this.reversedPathToHead, newHead); + } + + *[Symbol.iterator]() { + let head: ListZipper = this; + for (let prev = head.previous(); prev.present(); prev = prev.flatMap((p) => p.previous())) { + head = >prev.get(); + } + if (head.currentHead.present()) yield* head.currentHead.get(); + } + + public collection() { + return Array.from(this); + } + + static from(iterable: Iterable): ListZipper { + return new ListZipper(Optional.none(), Cons.from(iterable)); + } +} diff --git a/u/types/fn/callable.ts b/u/types/fn/callable.ts new file mode 100644 index 0000000..51756d7 --- /dev/null +++ b/u/types/fn/callable.ts @@ -0,0 +1,19 @@ +export interface Callable { + (...args: Array): T; +} + +export interface Supplier extends Callable { + (): T; +} + +export interface Mapper extends Callable { + (t: T): U; +} + +export interface BiMapper extends Callable { + (t: T, u: U): R; +} + +export interface SideEffect extends Mapper {} + +export interface BiSideEffect extends BiMapper {} diff --git a/u/types/fn/either.ts b/u/types/fn/either.ts new file mode 100644 index 0000000..6140ada --- /dev/null +++ b/u/types/fn/either.ts @@ -0,0 +1,106 @@ +import { IOptional, type Mapper, Optional, type Supplier, Tagged, isTagged } from '@emprespresso/pengueno'; + +export const IEitherTag = 'IEither' as const; +export type IEitherTag = typeof IEitherTag; +export const isEither = (o: unknown): o is IEither => isTagged(o, IEitherTag); +export interface IEither extends Tagged { + readonly mapBoth: <_E, _T>(errBranch: Mapper, okBranch: Mapper) => IEither<_E, _T>; + readonly fold: <_T>(leftFolder: Mapper, rightFolder: Mapper) => _T; + readonly left: Supplier>; + readonly right: Supplier>; + readonly moveRight: <_T>(t: _T) => IEither; + readonly mapRight: <_T>(mapper: Mapper) => IEither; + readonly mapLeft: <_E>(mapper: Mapper) => IEither<_E, T>; + readonly flatMap: <_T>(mapper: Mapper>) => IEither; + readonly flatMapAsync: <_T>(mapper: Mapper>>) => Promise>; +} + +const ELeftTag = 'E.Left' as const; +type ELeftTag = typeof ELeftTag; +export const isLeft = (o: unknown): o is Left => isTagged(o, ELeftTag); +interface Left extends Tagged { + err: E; +} + +const ERightTag = 'E.Right' as const; +type ERightTag = typeof ERightTag; +export const isRight = (o: unknown): o is Right => isTagged(o, ERightTag); +interface Right extends Tagged { + ok: T; +} + +class _Tagged implements Tagged { + protected constructor(public readonly _tag = IEitherTag) {} +} + +export class Either extends _Tagged implements IEither { + protected constructor(private readonly self: Left | Right) { + super(); + } + + public moveRight<_T>(t: _T) { + return this.mapRight(() => t); + } + + public mapBoth<_E, _T>(errBranch: Mapper, okBranch: Mapper): 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): IEither { + if (isRight(this.self)) return Either.right(mapper(this.self.ok)); + return Either.left(this.self.err); + } + + public mapLeft<_E>(mapper: Mapper): 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>): IEither { + if (isRight(this.self)) return mapper(this.self.ok); + return Either.left(this.self.err); + } + + public async flatMapAsync<_T>(mapper: Mapper>>): Promise> { + 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, rightFolder: Mapper): _T { + if (isLeft(this.self)) return leftFolder(this.self.err); + return rightFolder(this.self.ok); + } + + public left(): IOptional { + if (isLeft(this.self)) return Optional.from(this.self.err) as IOptional; + return Optional.none(); + } + + public right(): IOptional { + if (isRight(this.self)) return Optional.from(this.self.ok) as IOptional; + return Optional.none(); + } + + static left(e: E): IEither { + return new Either({ err: e, _tag: ELeftTag }); + } + + static right(t: T): IEither { + return new Either({ ok: t, _tag: ERightTag }); + } + + static fromFailable(s: Supplier): IEither { + try { + return Either.right(s()); + } catch (e) { + return Either.left(e as E); + } + } + + static async fromFailableAsync(s: Supplier> | Promise): Promise> { + return await (typeof s === 'function' ? s() : s) + .then((t: T) => Either.right(t)) + .catch((e: E) => Either.left(e)); + } +} diff --git a/u/types/fn/index.ts b/u/types/fn/index.ts new file mode 100644 index 0000000..780c86c --- /dev/null +++ b/u/types/fn/index.ts @@ -0,0 +1,3 @@ +export * from './callable.js'; +export * from './either.js'; +export * from './optional.js'; diff --git a/u/types/fn/optional.ts b/u/types/fn/optional.ts new file mode 100644 index 0000000..3396a45 --- /dev/null +++ b/u/types/fn/optional.ts @@ -0,0 +1,93 @@ +import { type Mapper, type Supplier, Tagged, isTagged } from '@emprespresso/pengueno'; + +export type MaybeGiven = T | undefined | null; + +export const IOptionalTag = 'IOptional' as const; +export type IOptionalTag = typeof IOptionalTag; +export const isOptional = (o: unknown): o is IOptional => isTagged(o, IOptionalTag); +export class IOptionalEmptyError extends Error {} +export interface IOptional = NonNullable> extends Tagged, Iterable { + readonly move: <_T>(t: MaybeGiven<_T>) => IOptional<_T>; + readonly map: <_T>(mapper: Mapper>) => IOptional<_T>; + readonly filter: (mapper: Mapper) => IOptional; + readonly flatMap: <_T>(mapper: Mapper>>) => IOptional<_T>; + readonly orSome: (supplier: Supplier>) => IOptional; + readonly get: Supplier; + readonly present: Supplier; +} + +type OSomeTag = typeof OSomeTag; +const OSomeTag = 'O.Some' as const; +interface Some extends Tagged { + value: NonNullable; +} + +const ONoneTag = 'O.None' as const; +type ONoneTag = typeof ONoneTag; +interface None extends Tagged {} + +const isNone = (o: unknown): o is None => isTagged(o, ONoneTag); +const isSome = (o: unknown): o is Some => isTagged(o, OSomeTag); + +class _Tagged implements Tagged { + protected constructor(public readonly _tag = IOptionalTag) {} +} + +export class Optional = NonNullable> extends _Tagged implements IOptional { + private constructor(private readonly self: Some | None) { + super(); + } + + public move<_T>(t: MaybeGiven<_T>): IOptional<_T> { + return this.map(() => t); + } + + public orSome(supplier: Supplier>): IOptional { + if (isNone(this.self)) return Optional.from(supplier()); + return this; + } + + public get(): T { + if (isNone(this.self)) throw new IOptionalEmptyError('empty value'); + return this.self.value; + } + + public filter(mapper: Mapper): IOptional { + if (isNone(this.self) || !mapper(this.self.value)) return Optional.none(); + return Optional.some(this.self.value); + } + + public map<_T>(mapper: Mapper>): IOptional<_T> { + if (isNone(this.self)) return Optional.none(); + return Optional.from(mapper(this.self.value)) as IOptional<_T>; + } + + public flatMap<_T>(mapper: Mapper>>): 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 = NonNullable>(value: T): IOptional { + return new Optional({ value, _tag: OSomeTag }); + } + + private static readonly _none = new Optional({ _tag: ONoneTag }); + static none(): IOptional { + return this._none as unknown as IOptional; + } + + static from = NonNullable>(value: MaybeGiven): IOptional { + if (value === null || value === undefined) return Optional.none(); + return Optional.some(value); + } +} diff --git a/u/types/index.ts b/u/types/index.ts new file mode 100644 index 0000000..c68cedc --- /dev/null +++ b/u/types/index.ts @@ -0,0 +1,5 @@ +export * from './object.js'; +export * from './tagged.js'; + +export * from './fn/index.js'; +export * from './collections/index.js'; diff --git a/u/types/object.ts b/u/types/object.ts new file mode 100644 index 0000000..fe97999 --- /dev/null +++ b/u/types/object.ts @@ -0,0 +1 @@ +export const isObject = (o: unknown): o is object => typeof o === 'object' && !Array.isArray(o) && !!o; diff --git a/u/types/tagged.ts b/u/types/tagged.ts new file mode 100644 index 0000000..351e4c9 --- /dev/null +++ b/u/types/tagged.ts @@ -0,0 +1,8 @@ +import { isObject } from './index.js'; + +export interface Tagged { + _tag: TTag; +} + +export const isTagged = (o: unknown, tag: TTag): o is Tagged => + !!(isObject(o) && '_tag' in o && o._tag === tag); diff --git a/worker/executor.ts b/worker/executor.ts index f4b7906..bfcbc37 100644 --- a/worker/executor.ts +++ b/worker/executor.ts @@ -13,74 +13,58 @@ import { import type { Job, JobArgT, Pipeline } from '@emprespresso/ci_model'; // -- -- -const jobTypeMetric = memoize((type: string) => Metric.fromName(`run.${type}`)); -export const executeJob = (tJob: ITraceable) => - tJob - .bimap(TraceUtil.withMetricTrace(jobTypeMetric(tJob.get().type))) +const jobTypeMetric = memoize((type: string) => Metric.fromName(`run.${type}`).asResult()); +export const executeJob = (tJob: ITraceable) => { + const metric = jobTypeMetric(tJob.get().type); + return tJob + .flatMap(TraceUtil.withMetricTrace(metric)) .peek((tJob) => tJob.trace.trace(`let's do this little job ok!! ${tJob.get()}`)) .map((tJob) => validateExecutionEntries(tJob.get().arguments) .mapLeft((badEntries) => { - tJob.trace.addTrace(LogLevel.ERROR).trace(badEntries.toString()); + tJob.trace.traceScope(LogLevel.ERROR).trace(badEntries.toString()); return new Error('invalid job arguments'); }) .flatMapAsync((args) => getStdout(tJob.move(tJob.get().type), { env: args })), ) - .peek( - TraceUtil.promiseify((q) => - q.trace.trace( - q.get().fold(({ isLeft }) => jobTypeMetric(tJob.get().type)[isLeft ? 'failure' : 'success']), - ), - ), - ) + .flatMapAsync(TraceUtil.promiseify(TraceUtil.traceResultingEither(metric))) .get(); +}; // -- -- // -- -- -const pipelinesMetric = Metric.fromName('pipelines'); +const pipelinesMetric = Metric.fromName('pipelines').asResult(); export const executePipeline = ( tPipeline: ITraceable, baseEnv?: JobArgT, ): Promise> => tPipeline - .bimap(TraceUtil.withFunctionTrace(executePipeline)) - .bimap(TraceUtil.withMetricTrace(pipelinesMetric)) - .map(async (tJobs): Promise> => { - for (const [i, serialStage] of tJobs.get().serialJobs.entries()) { - tJobs.trace.trace(`executing stage ${i}. do your best little stage :>\n${serialStage}`); - const jobResults = await Promise.all( - serialStage.parallelJobs.map((job) => - tJobs - .bimap((_) => [job, `stage ${i}`]) - .map( - (tJob) => - { - ...tJob.get(), - arguments: { - ...baseEnv, - ...tJob.get().arguments, - }, - }, - ) + .flatMap(TraceUtil.withFunctionTrace(executePipeline)) + .flatMap(TraceUtil.withMetricTrace(pipelinesMetric)) + .map(async (_tPipeline): Promise> => { + for (const [i, serialStage] of tPipeline.get().serialJobs.entries()) { + const tPipeline = _tPipeline.flatMap(TraceUtil.withTrace(`Stage = ${i}`)); + const parallelJobs = tPipeline + .peek((t) => t.trace.trace(`do your best little stage :> ${serialStage}`)) + .move(serialStage.parallelJobs) + .coExtend((jobs) => + jobs.get().map((job) => { ...job, arguments: { ...baseEnv, ...job.arguments } }), + ) + .map((job) => { + const metric = jobTypeMetric(job.get().type); + return job + .flatMap(TraceUtil.withMetricTrace(metric)) .map(executeJob) - .peek( - TraceUtil.promiseify((tEitherJobOutput) => - tEitherJobOutput - .get() - .mapRight((stdout) => tEitherJobOutput.trace.addTrace('STDOUT').trace(stdout)), - ), - ) - .get(), - ), - ); - const failures = jobResults.filter((e) => e.fold(({ isLeft }) => isLeft)); + .flatMapAsync(TraceUtil.promiseify(TraceUtil.traceResultingEither(metric))); + }); + const results = await Promise.all(parallelJobs.map((job) => job.get())); + const failures = results.filter((e) => e.left); if (failures.length > 0) { - tJobs.trace.trace(pipelinesMetric.failure); return Either.left(new Error(failures.toString())); } } - tJobs.trace.trace(pipelinesMetric.success); - return Either.right(undefined); + return Either.right(undefined); }) + .flatMapAsync(TraceUtil.promiseify(TraceUtil.traceResultingEither(pipelinesMetric))) .get(); // -- -- diff --git a/worker/scripts/ansible_playbook.ts b/worker/scripts/ansible_playbook.ts index c6d8f2c..4a22984 100755 --- a/worker/scripts/ansible_playbook.ts +++ b/worker/scripts/ansible_playbook.ts @@ -28,9 +28,9 @@ const eitherJob = getRequiredEnvVars(['path', 'playbooks']).mapRight( const eitherVault = Bitwarden.getConfigFromEnvironment().mapRight((config) => new Bitwarden(config)); const playbookMetric = Metric.fromName('ansiblePlaybook.playbook'); -const _logJob = LogTraceable.of(eitherJob).bimap(TraceUtil.withTrace('ansible_playbook')); +const _logJob = LogTraceable.of(eitherJob).flatMap(TraceUtil.withTrace('ansible_playbook')); await LogMetricTraceable.ofLogTraceable(_logJob) - .bimap(TraceUtil.withMetricTrace(playbookMetric)) + .flatMap(TraceUtil.withMetricTrace(playbookMetric)) .peek((tEitherJob) => tEitherJob.trace.trace('starting ansible playbook job! (⑅˘꒳˘)')) .map((tEitherJob) => tEitherJob.get().flatMapAsync((job) => diff --git a/worker/scripts/build_docker_image.ts b/worker/scripts/build_docker_image.ts index 228dfcc..b35031a 100755 --- a/worker/scripts/build_docker_image.ts +++ b/worker/scripts/build_docker_image.ts @@ -3,7 +3,6 @@ import { getRequiredEnvVars, getStdout, - LogLevel, LogTraceable, LogMetricTraceable, Metric, @@ -29,17 +28,18 @@ const eitherJob = getRequiredEnvVars([ ); const eitherVault = Bitwarden.getConfigFromEnvironment().mapRight((config) => new Bitwarden(config)); -const buildImageMetric = Metric.fromName('dockerImage.build'); -const loginMetric = Metric.fromName('dockerRegistry.login'); -const _logJob = LogTraceable.of(eitherJob).bimap((tEitherJob) => { - const trace = - 'build_docker_image.' + - tEitherJob.get().fold(({ isRight, value }) => (isRight ? value.arguments.buildTarget : '')); - return [tEitherJob.get(), trace]; +const buildImageMetric = Metric.fromName('dockerImage.build').asResult(); +const loginMetric = Metric.fromName('dockerRegistry.login').asResult(); +const _logJob = LogTraceable.of(eitherJob).flatMap((tEitherJob) => { + const trace = tEitherJob.get().fold( + () => 'NO_BUILD_TARGET', + ({ arguments: { buildTarget } }) => buildTarget, + ); + return tEitherJob.traceScope(() => `build_docker_image.${trace}`); }); await LogMetricTraceable.ofLogTraceable(_logJob) - .bimap(TraceUtil.withMetricTrace(buildImageMetric)) - .bimap(TraceUtil.withMetricTrace(loginMetric)) + .flatMap(TraceUtil.withMetricTrace(buildImageMetric)) + .flatMap(TraceUtil.withMetricTrace(loginMetric)) .peek((tEitherJob) => tEitherJob.trace.trace('starting docker image build job! (⑅˘꒳˘)')) .map((tEitherJob) => tEitherJob.get().flatMapAsync((job) => @@ -68,12 +68,7 @@ await LogMetricTraceable.ofLogTraceable(_logJob) }), ); }) - .peek(async (tEitherWithAuthdRegistry) => { - const eitherWithAuthdRegistry = await tEitherWithAuthdRegistry.get(); - return tEitherWithAuthdRegistry.trace.trace( - eitherWithAuthdRegistry.fold(({ isLeft }) => loginMetric[isLeft ? 'failure' : 'success']), - ); - }) + .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(loginMetric))) .map(async (tEitherWithAuthdRegistryBuildJob) => { const eitherWithAuthdRegistryBuildJob = await tEitherWithAuthdRegistryBuildJob.get(); tEitherWithAuthdRegistryBuildJob.trace.trace('finally building the image~ (◕ᴗ◕✿)'); @@ -92,19 +87,15 @@ await LogMetricTraceable.ofLogTraceable(_logJob) eitherWithAuthdRegistryBuildJob.mapRight((job) => ({ buildOutput, job })), ); }) - .peek(async (tEitherWithBuiltImage) => { - const eitherWithBuiltImage = await tEitherWithBuiltImage.get(); - eitherWithBuiltImage.fold(({ isLeft, value }) => { - tEitherWithBuiltImage.trace.trace(buildImageMetric[isLeft ? 'failure' : 'success']); - if (isLeft) { - tEitherWithBuiltImage.trace - .addTrace(LogLevel.ERROR) - .trace(`oh nyoo we couldn't buiwd the img :(( ${value}`); - return; - } - tEitherWithBuiltImage.trace.addTrace('buildOutput').trace(value.buildOutput); - }); - }) + .flatMapAsync(TraceUtil.promiseify(TraceUtil.traceResultingEither(buildImageMetric))) + .peek( + TraceUtil.promiseify((tBuilt) => + tBuilt.get().fold( + (err) => tBuilt.trace.trace(`oh nyoo we couldn't buiwd the img :(( ${err}`), + (ok) => tBuilt.trace.traceScope('buildOutput').trace(ok.buildOutput), + ), + ), + ) .map(async (tEitherWithBuiltImage) => { const eitherWithBuiltImage = await tEitherWithBuiltImage.get(); return eitherWithBuiltImage diff --git a/worker/scripts/checkout_ci.ts b/worker/scripts/checkout_ci.ts index 8e4dcca..fb71a16 100755 --- a/worker/scripts/checkout_ci.ts +++ b/worker/scripts/checkout_ci.ts @@ -11,6 +11,7 @@ import { Metric, prependWith, TraceUtil, + IEither, } from '@emprespresso/pengueno'; import { mkdir, readFile, rm } from 'fs/promises'; import { join } from 'path'; @@ -29,11 +30,14 @@ const eitherJob = getRequiredEnvVars(['remote', 'refname', 'rev']).mapRight( }, }, ); +const afterJob = eitherJob.flatMapAsync((job) => + Either.fromFailableAsync(() => rm(getWorkingDirectoryForCiJob(job), { recursive: true })), +); const ciRunMetric = Metric.fromName('checkout_ci.run'); -const _logJob = LogTraceable.of(eitherJob).bimap(TraceUtil.withTrace(`checkout_ci.${run}`)); +const _logJob = LogTraceable.of(eitherJob).flatMap(TraceUtil.withTrace(`checkout_ci.${run}`)); await LogMetricTraceable.ofLogTraceable(_logJob) - .bimap(TraceUtil.withMetricTrace(ciRunMetric)) + .flatMap(TraceUtil.withMetricTrace(ciRunMetric)) .map((tEitherJob) => tEitherJob.get().flatMapAsync((ciJob) => { const wd = getWorkingDirectoryForCiJob(ciJob); @@ -53,29 +57,28 @@ await LogMetricTraceable.ofLogTraceable(_logJob) ); }), ) - .map((tEitherCiJob) => - tEitherCiJob.get().then((eitherCiJob) => - eitherCiJob.flatMapAsync<{ cmd: Command; job: CheckoutCiJob }>((ciJob) => - Either.fromFailableAsync(() => - readFile(join(getSrcDirectoryForCiJob(ciJob), CI_WORKFLOW_FILE), 'utf-8'), - ).then((eitherWorkflowJson) => - eitherWorkflowJson - .flatMap((json) => Either.fromFailable(JSON.parse(json))) - .flatMap((eitherWorkflowParse) => { - if (isCiWorkflow(eitherWorkflowParse)) { - return Either.right({ - cmd: getPipelineGenerationCommand(ciJob, eitherWorkflowParse.workflow), - job: ciJob, - }); - } - return Either.left( - new Error("couldn't find any valid ci configuration (。•́︿•̀。), that's okay~"), - ); - }), - ), + .map(async (tEitherCiJob) => { + const eitherCiJob = await tEitherCiJob.get(); + const repoCiFileContents = await eitherCiJob.flatMapAsync((ciJob) => + Either.fromFailableAsync(() => + readFile(join(getSrcDirectoryForCiJob(ciJob), CI_WORKFLOW_FILE), 'utf-8'), ), - ), - ) + ); + return repoCiFileContents + .flatMap((fileText) => Either.fromFailable(() => JSON.parse(fileText))) + .flatMap((json) => { + return eitherCiJob.flatMap((ciJob): IEither => { + if (!isCiWorkflow(json)) { + const e = new Error("couldn't find any valid ci configuration (。•́︿•̀。), that's okay~"); + return Either.left(e); + } + return Either.right({ + cmd: getPipelineGenerationCommand(ciJob, json.workflow), + job: ciJob, + }); + }); + }); + }) .map(async (tEitherPipelineGenerationCommand) => { const eitherJobCommand = await tEitherPipelineGenerationCommand.get(); const eitherPipeline = await eitherJobCommand.flatMapAsync((jobCommand) => @@ -107,17 +110,14 @@ await LogMetricTraceable.ofLogTraceable(_logJob) .get(), ); }) - .get() - .then((e) => - e - .flatMap(() => eitherJob) - .fold(({ isLeft, isRight, value }) => { - if (isLeft || !isRight) throw value; - return rm(getWorkingDirectoryForCiJob(value), { - recursive: true, - }); - }), - ); + .map(async (tCompletePipeline) => { + const completePipeline = await tCompletePipeline.get(); + return completePipeline.fold( + (e) => Promise.reject(e), + () => afterJob, + ); + }) + .get(); const getWorkingDirectoryForCiJob = (job: CheckoutCiJob) => `${job.arguments.returnPath}/${job.arguments.run}`; @@ -130,7 +130,7 @@ const getPipelineGenerationCommand = ( pipelineGeneratorPath: string, image = _image, runFlags = _runFlags, -) => [ +): Command => [ 'docker', 'run', ...runFlags, diff --git a/worker/secret.ts b/worker/secret.ts index e3edb2d..e533b16 100644 --- a/worker/secret.ts +++ b/worker/secret.ts @@ -10,18 +10,21 @@ import { } from '@emprespresso/pengueno'; // -- -- -export interface LoginItem { +export interface SecretItem { + name: string; +} + +export interface LoginItem extends SecretItem { login: { username: string; password: string; }; } -export interface SecureNote { +export interface SecureNote extends SecretItem { notes: string; } -export type SecretItem = LoginItem | SecureNote; export interface IVault { unlock: (client: TClient) => Promise>; lock: (client: TClient, key: TKey) => Promise>; @@ -30,7 +33,7 @@ export interface IVault { } // -- -- -// -- -- +// -- -- type TClient = ITraceable; type TKey = string; type TItemId = string; @@ -38,9 +41,9 @@ export class Bitwarden implements IVault { constructor(private readonly config: BitwardenConfig) {} public unlock(client: TClient) { - return client + const authed = client .move(this.config) - .bimap(TraceUtil.withMetricTrace(Bitwarden.loginMetric)) + .flatMap(TraceUtil.withMetricTrace(Bitwarden.loginMetric)) .flatMap((tConfig) => tConfig.move(`bw config server ${tConfig.get().server}`).map(getStdout)) .map(async (tEitherWithConfig) => { const eitherWithConfig = await tEitherWithConfig.get(); @@ -49,12 +52,9 @@ export class Bitwarden implements IVault { tEitherWithConfig.move('bw login --apikey --quiet').map(getStdout).get(), ); }) - .peek(async (tEitherWithAuthd) => { - const eitherWithAuthd = await tEitherWithAuthd.get(); - return tEitherWithAuthd.trace.trace( - eitherWithAuthd.fold(({ isLeft }) => Bitwarden.loginMetric[isLeft ? 'failure' : 'success']), - ); - }) + .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(Bitwarden.loginMetric))); + const unlocked = authed + .flatMap(TraceUtil.withMetricTrace(Bitwarden.unlockVaultMetric)) .map(async (tEitherWithAuthd) => { const eitherWithAuthd = await tEitherWithAuthd.get(); tEitherWithAuthd.trace.trace('unlocking the secret vault~ (◕ᴗ◕✿)'); @@ -62,19 +62,14 @@ export class Bitwarden implements IVault { tEitherWithAuthd.move('bw unlock --passwordenv BW_PASSWORD --raw').map(getStdout).get(), ); }) - .peek(async (tEitherWithSession) => { - const eitherWithAuthd = await tEitherWithSession.get(); - return tEitherWithSession.trace.trace( - eitherWithAuthd.fold(({ isLeft }) => Bitwarden.unlockVaultMetric[isLeft ? 'failure' : 'success']), - ); - }) - .get(); + .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(Bitwarden.unlockVaultMetric))); + return unlocked.get(); } public fetchSecret(client: TClient, key: string, item: string): Promise> { return client .move(key) - .bimap(TraceUtil.withMetricTrace(Bitwarden.fetchSecretMetric)) + .flatMap(TraceUtil.withMetricTrace(Bitwarden.fetchSecretMetric)) .peek((tSession) => tSession.trace.trace(`looking for your secret ${item} (⑅˘꒳˘)`)) .flatMap((tSession) => tSession.move('bw list items').map((listCmd) => @@ -88,8 +83,7 @@ export class Bitwarden implements IVault { tEitherItemsJson .get() .flatMap( - (itemsJson): IEither> => - Either.fromFailable(() => JSON.parse(itemsJson)), + (itemsJson): IEither> => Either.fromFailable(() => JSON.parse(itemsJson)), ) .flatMap((itemsList): IEither => { const secret = itemsList.find(({ name }) => name === item); @@ -100,19 +94,14 @@ export class Bitwarden implements IVault { }), ), ) - .peek(async (tEitherWithSecret) => { - const eitherWithSecret = await tEitherWithSecret.get(); - return tEitherWithSecret.trace.trace( - eitherWithSecret.fold(({ isLeft }) => Bitwarden.fetchSecretMetric[isLeft ? 'failure' : 'success']), - ); - }) + .flatMapAsync(TraceUtil.promiseify(TraceUtil.traceResultingEither(Bitwarden.fetchSecretMetric))) .get(); } public lock(client: TClient, key: TKey) { return client .move(key) - .bimap(TraceUtil.withMetricTrace(Bitwarden.lockVaultMetric)) + .flatMap(TraceUtil.withMetricTrace(Bitwarden.lockVaultMetric)) .peek((tSession) => tSession.trace.trace(`taking care of locking the vault :3`)) .flatMap((tSession) => tSession.move('bw lock').map((lockCmd) => @@ -121,14 +110,14 @@ export class Bitwarden implements IVault { }), ), ) - .peek(async (tEitherWithLocked) => { - const eitherWithLocked = await tEitherWithLocked.get(); - return eitherWithLocked.fold(({ isLeft }) => { - tEitherWithLocked.trace.trace(Bitwarden.lockVaultMetric[isLeft ? 'failure' : 'success']); - if (isLeft) return; - tEitherWithLocked.trace.trace('all locked up and secure now~ (。•̀ᴗ-)✧'); - }); - }) + .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(Bitwarden.lockVaultMetric))) + .peek( + TraceUtil.promiseify((tEitherWithLocked) => + tEitherWithLocked + .get() + .mapRight(() => tEitherWithLocked.trace.trace('all locked up and secure now~ (。•̀ᴗ-)✧')), + ), + ) .get(); } @@ -142,10 +131,10 @@ export class Bitwarden implements IVault { ); } - private static loginMetric = Metric.fromName('Bitwarden.login'); - private static unlockVaultMetric = Metric.fromName('Bitwarden.unlockVault'); - private static fetchSecretMetric = Metric.fromName('Bitwarden.fetchSecret'); - private static lockVaultMetric = Metric.fromName('Bitwarden.lock'); + private static loginMetric = Metric.fromName('Bitwarden.login').asResult(); + private static unlockVaultMetric = Metric.fromName('Bitwarden.unlockVault').asResult(); + private static fetchSecretMetric = Metric.fromName('Bitwarden.fetchSecret').asResult(); + private static lockVaultMetric = Metric.fromName('Bitwarden.lock').asResult(); } export interface BitwardenConfig { @@ -153,4 +142,4 @@ export interface BitwardenConfig { secret: string; clientId: string; } -// -- -- +// -- -- -- cgit v1.2.3-70-g09d2