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 --- 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 +++++++++++++++++++++++++++ 16 files changed, 294 insertions(+), 185 deletions(-) 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 (limited to 'u/server') 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; + } +} -- cgit v1.2.3-70-g09d2