diff options
author | Elizabeth Hunt <me@liz.coffee> | 2025-06-29 17:31:30 -0700 |
---|---|---|
committer | Elizabeth Hunt <me@liz.coffee> | 2025-06-29 17:31:30 -0700 |
commit | 58be1809c46cbe517a18d86d0af52179dcc5cbf6 (patch) | |
tree | 9ccc678b3fd48c1a52fe501600dd2c2051740a55 /u/server | |
parent | d4791f3d357634daf506fb8f91cc5332a794c421 (diff) | |
download | ci-58be1809c46cbe517a18d86d0af52179dcc5cbf6.tar.gz ci-58be1809c46cbe517a18d86d0af52179dcc5cbf6.zip |
Move to nodejs and also lots of significant refactoring that should've been broken up but idgaf
Diffstat (limited to 'u/server')
-rw-r--r-- | u/server/activity/health.ts | 40 | ||||
-rw-r--r-- | u/server/filter/index.ts | 2 | ||||
-rw-r--r-- | u/server/filter/json.ts | 20 | ||||
-rw-r--r-- | u/server/filter/method.ts | 30 | ||||
-rw-r--r-- | u/server/http/body.ts | 10 | ||||
-rw-r--r-- | u/server/http/index.ts | 3 | ||||
-rw-r--r-- | u/server/http/method.ts | 1 | ||||
-rw-r--r-- | u/server/http/status.ts | 71 | ||||
-rw-r--r-- | u/server/index.ts | 12 | ||||
-rw-r--r-- | u/server/request.ts | 39 | ||||
-rw-r--r-- | u/server/request/index.ts | 18 | ||||
-rw-r--r-- | u/server/request/pengueno.ts | 44 | ||||
-rw-r--r-- | u/server/response.ts | 83 | ||||
-rw-r--r-- | u/server/response/index.ts | 18 | ||||
-rw-r--r-- | u/server/response/json_pengueno.ts | 29 | ||||
-rw-r--r-- | u/server/response/pengueno.ts | 59 |
16 files changed, 294 insertions, 185 deletions
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<ITraceable<HealthCheckInput, ServerTrace>, Promise<IEither<Error, HealthCheckOutput>>> {} export class HealthCheckActivityImpl implements IHealthCheckActivity { @@ -31,36 +31,18 @@ export class HealthCheckActivityImpl implements IHealthCheckActivity { public checkHealth(req: ITraceable<PenguenoRequest, ServerTrace>) { 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<PenguenoRequest, ServerTrace>, > { - (req: RIn): Promise<IEither<Err, T>>; + (req: RIn): IEither<Err, T> | Promise<IEither<Err, T>>; } 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<R, ParsedJson = unknown> { (json: ITraceable<ParsedJson, ServerTrace>): IEither<PenguenoError, R>; } -const ParseJsonMetric = Metric.fromName('JsonParse'); +const ParseJsonMetric = Metric.fromName('JsonParse').asResult(); export const jsonModel = <MessageT>(jsonTransformer: JsonTransformer<MessageT>): RequestFilter<MessageT> => (r: ITraceable<PenguenoRequest, ServerTrace>) => r - .bimap(TraceUtil.withFunctionTrace(jsonModel)) - .bimap(TraceUtil.withMetricTrace(ParseJsonMetric)) + .flatMap(TraceUtil.withFunctionTrace(jsonModel)) + .flatMap(TraceUtil.withMetricTrace(ParseJsonMetric)) .map((j) => - Either.fromFailableAsync<Error, MessageT>(<Promise<MessageT>>j.get().json()).then((either) => + Either.fromFailableAsync<Error, MessageT>(<Promise<MessageT>>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<HttpMethod>): RequestFilter<HttpMethod> => (req: ITraceable<PenguenoRequest, ServerTrace>) => req - .bimap(TraceUtil.withFunctionTrace(requireMethod)) - .move(Promise.resolve(req.get())) - .map( - TraceUtil.promiseify((t) => { - const { method: _method } = t.get(); - const method = <HttpMethod>_method; - if (!methods.includes(method)) { - const msg = "that's not how you pet me (⋟﹏⋞)~"; - t.trace.addTrace(LogLevel.WARN).trace(msg); - return Either.left<PenguenoError, HttpMethod>(new PenguenoError(msg, 405)); - } - return Either.right<PenguenoError, HttpMethod>(method); - }), - ) + .flatMap(TraceUtil.withFunctionTrace(requireMethod)) + .map((t): IEither<PenguenoError, HttpMethod> => { + const { + req: { method }, + } = t.get(); + if (!methods.includes(method)) { + const msg = "that's not how you pet me (⋟﹏⋞)~"; + t.trace.traceScope(LogLevel.WARN).trace(msg); + return Either.left(new PenguenoError(msg, 405)); + } + return Either.right(method); + }) .get(); diff --git a/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<Uint8Array> + | Blob + | FormData + | Iterable<Uint8Array> + | 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<number, string> = { + 100: 'Continue', + 101: 'Switching Protocols', + 102: 'Processing (WebDAV)', + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 207: 'Multi-Status (WebDAV)', + 208: 'Already Reported (WebDAV)', + 226: 'IM Used', + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 306: '(Unused)', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect (experimental)', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Request Entity Too Large', + 414: 'Request-URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Requested Range Not Satisfiable', + 417: 'Expectation Failed', + 418: "I'm a teapot (RFC 2324)", + 420: 'Enhance Your Calm (Twitter)', + 422: 'Unprocessable Entity (WebDAV)', + 423: 'Locked (WebDAV)', + 424: 'Failed Dependency (WebDAV)', + 425: 'Reserved for WebDAV', + 426: 'Upgrade Required', + 428: 'Precondition Required', + 429: 'Too Many Requests', + 431: 'Request Header Fields Too Large', + 444: 'No Response (Nginx)', + 449: 'Retry With (Microsoft)', + 450: 'Blocked by Windows Parental Controls (Microsoft)', + 451: 'Unavailable For Legal Reasons', + 499: 'Client Closed Request (Nginx)', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', + 506: 'Variant Also Negotiates (Experimental)', + 507: 'Insufficient Storage (WebDAV)', + 508: 'Loop Detected (WebDAV)', + 509: 'Bandwidth Limit Exceeded (Apache)', + 510: 'Not Extended', + 511: 'Network Authentication Required', + 598: 'Network read timeout error', + 599: 'Network connect timeout error', +}; diff --git a/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<ITraceable<PenguenoRequest, ServerTrace>, Promise<PenguenoResponse>>; +} 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<string, string> { - const ServerRequestTime = this.at.getTime(); - const ServerResponseTime = Date.now(); - const DeltaTime = ServerResponseTime - ServerRequestTime; - const RequestId = this.id; - - return Object.entries({ - RequestId, - ServerRequestTime, - ServerResponseTime, - DeltaTime, - Hai: penguenoGreeting(), - }).reduce((acc, [key, val]) => ({ ...acc, [key]: val!.toString() }), {}); - } - - public static from(request: Request): LogMetricTraceable<PenguenoRequest> { - const id = crypto.randomUUID(); - const url = new URL(request.url); - const { pathname } = url; - const 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<string, string>; + + formData(): Promise<FormData>; + json(): Promise<unknown>; + text(): Promise<string>; + + param(key: string): string | undefined; + query(): Record<string, string>; + queries(): Record<string, string[]>; +} + +export * from './pengueno.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<string, string> { + const RequestId = this.id; + const RequestReceivedUnix = this.at.getTime(); + const RequestHandleUnix = Date.now(); + const DeltaUnix = this.elapsedTimeMs(() => RequestHandleUnix); + const Hai = penguenoGreeting(); + + return Object.entries({ + RequestId, + RequestReceivedUnix, + RequestHandleUnix, + DeltaUnix, + Hai, + }).reduce((acc, [key, val]) => ({ ...acc, [key]: val!.toString() }), {}); + } + + public static from(request: ITraceable<BaseRequest, ServerTrace>): ITraceable<PenguenoRequest, ServerTrace> { + const id = crypto.randomUUID(); + return request.bimap((tRequest) => { + const request = tRequest.get(); + const url = new URL(request.url); + const { pathname } = url; + const trace = `RequestId = ${id}, Method = ${request.method}, Path = ${pathname}`; + + return { item: new PenguenoRequest(request, id, new Date()), trace }; + }); + } +} diff --git a/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<Uint8Array> - | Blob - | FormData - | Iterable<Uint8Array> - | NodeJS.ArrayBufferView - | URLSearchParams - | null - | string; -export type ResponseBody = object | string; -export type TResponseInit = Omit<ResponseInit, 'headers'> & { - status: number; - headers?: Record<string, string>; -}; - -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<string, string>, - }; -}; - -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<PenguenoRequest, ServerTrace>, 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<PenguenoRequest, ServerTrace>, - e: BodyInit | IEither<ResponseBody, ResponseBody>, - opts: TResponseInit, - ) { - const optsWithJsonContentType: TResponseInit = { - ...opts, - headers: { - ...opts.headers, - 'Content-Type': 'application/json', - }, - }; - if (isEither<ResponseBody, ResponseBody>(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<string, string>; + + body(): Body; +} + +export interface ResponseOpts { + status: number; + statusText?: string; + headers?: Record<string, string>; +} + +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<PenguenoRequest, ServerTrace>, e: Jsonable, _opts: ResponseOpts) { + const opts = { ..._opts, headers: { ..._opts.headers, 'Content-Type': 'application/json' } }; + if (isEither<Jsonable, Jsonable>(e)) { + super( + req, + JSON.stringify( + e.fold( + (error) => ({ error, ok: undefined }), + (ok) => ({ ok }), + ), + ), + opts, + ); + return; + } + super(req, JSON.stringify(Math.floor(opts.status / 100) > 4 ? { error: e } : { ok: e }), opts); + } +} diff --git a/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<string, string>) => { + const optHeaders = { + ...req.getResponseHeaders(), + ...extraHeaders, + }; + optHeaders['Content-Type'] = (optHeaders['Content-Type'] ?? 'text/plain') + '; charset=utf-8'; + return optHeaders; +}; + +const ResponseCodeMetrics = [0, 1, 2, 3, 4, 5].map((x) => Metric.fromName(`response.${x}xx`).asResult()); +export const getResponseMetrics = (status: number, elapsedMs?: number) => { + const index = Math.floor(status / 100); + return ResponseCodeMetrics.flatMap((metric, i) => + Optional.from(i) + .filter((i) => i === index) + .map(() => [metric.count.withValue(1.0)]) + .flatMap((metricValues) => + Optional.from(elapsedMs) + .map((ms) => metricValues.concat(metric.time.withValue(ms))) + .orSome(() => metricValues), + ) + .orSome(() => [metric.count.withValue(0.0)]) + .get(), + ); +}; + +export class PenguenoResponse implements BaseResponse { + public readonly statusText: string; + public readonly status: number; + public readonly headers: Record<string, string>; + + constructor( + req: ITraceable<PenguenoRequest, ServerTrace>, + private readonly _body: Body, + opts: ResponseOpts, + ) { + this.headers = getHeaders(req.get(), opts?.headers ?? {}); + this.status = opts.status; + this.statusText = opts.statusText ?? HttpStatusCodes[this.status]!; + + req.trace.trace(getResponseMetrics(opts.status, req.get().elapsedTimeMs())); + } + + public body() { + return this._body; + } +} |