summaryrefslogtreecommitdiff
path: root/lib/server
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-07-27 17:03:10 -0700
committerElizabeth Hunt <me@liz.coffee>2025-07-27 18:30:30 -0700
commit9970036d203ba2d0a46b35ba6fad21d49441cdd4 (patch)
treea585d13933bf4149dcb07e28526063d071453105 /lib/server
downloadpengueno-9970036d203ba2d0a46b35ba6fad21d49441cdd4.tar.gz
pengueno-9970036d203ba2d0a46b35ba6fad21d49441cdd4.zip
hai
Diffstat (limited to 'lib/server')
-rw-r--r--lib/server/activity/fourohfour.ts28
-rw-r--r--lib/server/activity/health.ts49
-rw-r--r--lib/server/activity/index.ts8
-rw-r--r--lib/server/filter/index.ts34
-rw-r--r--lib/server/filter/json.ts42
-rw-r--r--lib/server/filter/method.ts30
-rw-r--r--lib/server/http/body.ts10
-rw-r--r--lib/server/http/index.ts3
-rw-r--r--lib/server/http/method.ts1
-rw-r--r--lib/server/http/status.ts71
-rw-r--r--lib/server/index.ts13
-rw-r--r--lib/server/request/index.ts18
-rw-r--r--lib/server/request/pengueno.ts44
-rw-r--r--lib/server/response/index.ts17
-rw-r--r--lib/server/response/pengueno.ts81
15 files changed, 449 insertions, 0 deletions
diff --git a/lib/server/activity/fourohfour.ts b/lib/server/activity/fourohfour.ts
new file mode 100644
index 0000000..cd90ba0
--- /dev/null
+++ b/lib/server/activity/fourohfour.ts
@@ -0,0 +1,28 @@
+import {
+ type IActivity,
+ type ITraceable,
+ JsonResponse,
+ type PenguenoRequest,
+ type ServerTrace,
+} from '@emprespresso/pengueno';
+
+const messages = [
+ 'D: meow-t found! your api call ran away!',
+ '404-bidden! but like...in a cute way >:3 !',
+ ':< your data went on a paw-sible vacation!',
+ 'uwu~ not found, but found our hearts instead!',
+];
+const randomFourOhFour = () => messages[Math.floor(Math.random() * messages.length)]!;
+
+export interface IFourOhFourActivity {
+ fourOhFour: IActivity;
+}
+
+export class FourOhFourActivityImpl implements IFourOhFourActivity {
+ public fourOhFour(req: ITraceable<PenguenoRequest, ServerTrace>) {
+ return req
+ .move(new JsonResponse(req, randomFourOhFour(), { status: 404 }))
+ .map((resp) => Promise.resolve(resp.get()))
+ .get();
+ }
+}
diff --git a/lib/server/activity/health.ts b/lib/server/activity/health.ts
new file mode 100644
index 0000000..9396490
--- /dev/null
+++ b/lib/server/activity/health.ts
@@ -0,0 +1,49 @@
+import {
+ type IActivity,
+ type IEither,
+ IMetric,
+ type ITraceable,
+ JsonResponse,
+ LogLevel,
+ type Mapper,
+ Metric,
+ type PenguenoRequest,
+ type ServerTrace,
+ TraceUtil,
+} from '@emprespresso/pengueno';
+
+export enum HealthCheckInput {
+ CHECK,
+}
+export enum HealthCheckOutput {
+ YAASSSLAYQUEEN,
+}
+
+export interface IHealthCheckActivity {
+ checkHealth: IActivity;
+}
+
+const healthCheckMetric = Metric.fromName('Health').asResult();
+export interface HealthChecker
+ extends Mapper<ITraceable<HealthCheckInput, ServerTrace>, Promise<IEither<Error, HealthCheckOutput>>> {}
+export class HealthCheckActivityImpl implements IHealthCheckActivity {
+ constructor(private readonly check: HealthChecker) {}
+
+ public checkHealth(req: ITraceable<PenguenoRequest, ServerTrace>) {
+ return req
+ .flatMap(TraceUtil.withFunctionTrace(this.checkHealth))
+ .flatMap(TraceUtil.withMetricTrace(healthCheckMetric))
+ .flatMap((r) => r.move(HealthCheckInput.CHECK).map((input) => this.check(input)))
+ .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(healthCheckMetric)))
+ .map(
+ TraceUtil.promiseify((h) => {
+ const { status, message } = h.get().fold(
+ () => ({ status: 500, message: 'err' }),
+ () => ({ status: 200, message: 'ok' }),
+ );
+ return new JsonResponse(req, message, { status });
+ }),
+ )
+ .get();
+ }
+}
diff --git a/lib/server/activity/index.ts b/lib/server/activity/index.ts
new file mode 100644
index 0000000..fc7c990
--- /dev/null
+++ b/lib/server/activity/index.ts
@@ -0,0 +1,8 @@
+import type { ITraceable, PenguenoRequest, PenguenoResponse, ServerTrace } from '@emprespresso/pengueno';
+
+export interface IActivity {
+ (req: ITraceable<PenguenoRequest, ServerTrace>): Promise<PenguenoResponse>;
+}
+
+export * from './health';
+export * from './fourohfour';
diff --git a/lib/server/filter/index.ts b/lib/server/filter/index.ts
new file mode 100644
index 0000000..509deb3
--- /dev/null
+++ b/lib/server/filter/index.ts
@@ -0,0 +1,34 @@
+import {
+ type IEither,
+ type ITraceable,
+ LogLevel,
+ type PenguenoRequest,
+ type ServerTrace,
+} from '@emprespresso/pengueno';
+
+export enum ErrorSource {
+ USER = LogLevel.WARN,
+ SYSTEM = LogLevel.ERROR,
+}
+
+export class PenguenoError extends Error {
+ public readonly source: ErrorSource;
+ constructor(
+ override readonly message: string,
+ public readonly status: number,
+ ) {
+ super(message);
+ this.source = Math.floor(status / 100) === 4 ? ErrorSource.USER : ErrorSource.SYSTEM;
+ }
+}
+
+export interface RequestFilter<
+ T,
+ Err extends PenguenoError = PenguenoError,
+ RIn = ITraceable<PenguenoRequest, ServerTrace>,
+> {
+ (req: RIn): IEither<Err, T> | Promise<IEither<Err, T>>;
+}
+
+export * from './method';
+export * from './json';
diff --git a/lib/server/filter/json.ts b/lib/server/filter/json.ts
new file mode 100644
index 0000000..bc53d47
--- /dev/null
+++ b/lib/server/filter/json.ts
@@ -0,0 +1,42 @@
+import {
+ Either,
+ type IEither,
+ type ITraceable,
+ LogLevel,
+ Metric,
+ PenguenoError,
+ type PenguenoRequest,
+ type RequestFilter,
+ type ServerTrace,
+ TraceUtil,
+} from '@emprespresso/pengueno';
+
+export interface JsonTransformer<R, ParsedJson = unknown> {
+ (json: ITraceable<ParsedJson, ServerTrace>): IEither<PenguenoError, R>;
+}
+
+const ParseJsonMetric = Metric.fromName('JsonParse').asResult();
+export const jsonModel =
+ <MessageT>(jsonTransformer: JsonTransformer<MessageT>): RequestFilter<MessageT> =>
+ (r: ITraceable<PenguenoRequest, ServerTrace>) =>
+ r
+ .flatMap(TraceUtil.withFunctionTrace(jsonModel))
+ .flatMap(TraceUtil.withMetricTrace(ParseJsonMetric))
+ .map((j) =>
+ Either.fromFailableAsync<Error, MessageT>(<Promise<MessageT>>j.get().req.json()).then((either) =>
+ either.mapLeft((errReason) => {
+ j.trace.traceScope(LogLevel.WARN).trace(errReason);
+ return new PenguenoError('seems to be invalid JSON (>//<) can you fix?', 400);
+ }),
+ ),
+ )
+ .flatMapAsync(TraceUtil.promiseify(TraceUtil.traceResultingEither(ParseJsonMetric)))
+ .map(
+ TraceUtil.promiseify((traceableEitherJson) =>
+ traceableEitherJson
+ .get()
+ .mapRight((j) => traceableEitherJson.move(j))
+ .flatMap(jsonTransformer),
+ ),
+ )
+ .get();
diff --git a/lib/server/filter/method.ts b/lib/server/filter/method.ts
new file mode 100644
index 0000000..7d6aa76
--- /dev/null
+++ b/lib/server/filter/method.ts
@@ -0,0 +1,30 @@
+import {
+ Either,
+ HttpMethod,
+ IEither,
+ type ITraceable,
+ LogLevel,
+ PenguenoError,
+ type PenguenoRequest,
+ type RequestFilter,
+ type ServerTrace,
+ TraceUtil,
+} from '@emprespresso/pengueno';
+
+export const requireMethod =
+ (methods: Array<HttpMethod>): RequestFilter<HttpMethod> =>
+ (req: ITraceable<PenguenoRequest, ServerTrace>) =>
+ req
+ .flatMap(TraceUtil.withFunctionTrace(requireMethod))
+ .map((t): IEither<PenguenoError, HttpMethod> => {
+ const {
+ req: { method },
+ } = t.get();
+ if (!methods.includes(method)) {
+ const msg = "that's not how you pet me (â‹Ÿīšâ‹ž)~";
+ t.trace.traceScope(LogLevel.WARN).trace(msg);
+ return Either.left(new PenguenoError(msg, 405));
+ }
+ return Either.right(method);
+ })
+ .get();
diff --git a/lib/server/http/body.ts b/lib/server/http/body.ts
new file mode 100644
index 0000000..5fc4caa
--- /dev/null
+++ b/lib/server/http/body.ts
@@ -0,0 +1,10 @@
+export type Body =
+ | ArrayBuffer
+ | AsyncIterable<Uint8Array>
+ | Blob
+ | FormData
+ | Iterable<Uint8Array>
+ | NodeJS.ArrayBufferView
+ | URLSearchParams
+ | null
+ | string;
diff --git a/lib/server/http/index.ts b/lib/server/http/index.ts
new file mode 100644
index 0000000..147d8c4
--- /dev/null
+++ b/lib/server/http/index.ts
@@ -0,0 +1,3 @@
+export * from './body';
+export * from './status';
+export * from './method';
diff --git a/lib/server/http/method.ts b/lib/server/http/method.ts
new file mode 100644
index 0000000..172d77a
--- /dev/null
+++ b/lib/server/http/method.ts
@@ -0,0 +1 @@
+export type HttpMethod = 'POST' | 'GET' | 'HEAD' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH';
diff --git a/lib/server/http/status.ts b/lib/server/http/status.ts
new file mode 100644
index 0000000..15cb30c
--- /dev/null
+++ b/lib/server/http/status.ts
@@ -0,0 +1,71 @@
+export const HttpStatusCodes: Record<number, string> = {
+ 100: 'Continue',
+ 101: 'Switching Protocols',
+ 102: 'Processing (WebDAV)',
+ 200: 'OK',
+ 201: 'Created',
+ 202: 'Accepted',
+ 203: 'Non-Authoritative Information',
+ 204: 'No Content',
+ 205: 'Reset Content',
+ 206: 'Partial Content',
+ 207: 'Multi-Status (WebDAV)',
+ 208: 'Already Reported (WebDAV)',
+ 226: 'IM Used',
+ 300: 'Multiple Choices',
+ 301: 'Moved Permanently',
+ 302: 'Found',
+ 303: 'See Other',
+ 304: 'Not Modified',
+ 305: 'Use Proxy',
+ 306: '(Unused)',
+ 307: 'Temporary Redirect',
+ 308: 'Permanent Redirect (experimental)',
+ 400: 'Bad Request',
+ 401: 'Unauthorized',
+ 402: 'Payment Required',
+ 403: 'Forbidden',
+ 404: 'Not Found',
+ 405: 'Method Not Allowed',
+ 406: 'Not Acceptable',
+ 407: 'Proxy Authentication Required',
+ 408: 'Request Timeout',
+ 409: 'Conflict',
+ 410: 'Gone',
+ 411: 'Length Required',
+ 412: 'Precondition Failed',
+ 413: 'Request Entity Too Large',
+ 414: 'Request-URI Too Long',
+ 415: 'Unsupported Media Type',
+ 416: 'Requested Range Not Satisfiable',
+ 417: 'Expectation Failed',
+ 418: "I'm a teapot (RFC 2324)",
+ 420: 'Enhance Your Calm (Twitter)',
+ 422: 'Unprocessable Entity (WebDAV)',
+ 423: 'Locked (WebDAV)',
+ 424: 'Failed Dependency (WebDAV)',
+ 425: 'Reserved for WebDAV',
+ 426: 'Upgrade Required',
+ 428: 'Precondition Required',
+ 429: 'Too Many Requests',
+ 431: 'Request Header Fields Too Large',
+ 444: 'No Response (Nginx)',
+ 449: 'Retry With (Microsoft)',
+ 450: 'Blocked by Windows Parental Controls (Microsoft)',
+ 451: 'Unavailable For Legal Reasons',
+ 499: 'Client Closed Request (Nginx)',
+ 500: 'Internal Server Error',
+ 501: 'Not Implemented',
+ 502: 'Bad Gateway',
+ 503: 'Service Unavailable',
+ 504: 'Gateway Timeout',
+ 505: 'HTTP Version Not Supported',
+ 506: 'Variant Also Negotiates (Experimental)',
+ 507: 'Insufficient Storage (WebDAV)',
+ 508: 'Loop Detected (WebDAV)',
+ 509: 'Bandwidth Limit Exceeded (Apache)',
+ 510: 'Not Extended',
+ 511: 'Network Authentication Required',
+ 598: 'Network read timeout error',
+ 599: 'Network connect timeout error',
+};
diff --git a/lib/server/index.ts b/lib/server/index.ts
new file mode 100644
index 0000000..99e3839
--- /dev/null
+++ b/lib/server/index.ts
@@ -0,0 +1,13 @@
+import type { ITraceable, LogMetricTraceSupplier, Mapper } from '@emprespresso/pengueno';
+export type ServerTrace = LogMetricTraceSupplier;
+
+export * from './http';
+export * from './response';
+export * from './request';
+export * from './activity';
+export * from './filter';
+
+import { PenguenoRequest, PenguenoResponse } from '@emprespresso/pengueno';
+export interface Server {
+ readonly serve: Mapper<ITraceable<PenguenoRequest, ServerTrace>, Promise<PenguenoResponse>>;
+}
diff --git a/lib/server/request/index.ts b/lib/server/request/index.ts
new file mode 100644
index 0000000..0fa1c8d
--- /dev/null
+++ b/lib/server/request/index.ts
@@ -0,0 +1,18 @@
+import { HttpMethod } from '@emprespresso/pengueno';
+
+export interface BaseRequest {
+ url: string;
+ method: HttpMethod;
+
+ header(): Record<string, string>;
+
+ formData(): Promise<FormData>;
+ json(): Promise<unknown>;
+ text(): Promise<string>;
+
+ param(key: string): string | undefined;
+ query(): Record<string, string>;
+ queries(): Record<string, string[]>;
+}
+
+export * from './pengueno';
diff --git a/lib/server/request/pengueno.ts b/lib/server/request/pengueno.ts
new file mode 100644
index 0000000..31563e9
--- /dev/null
+++ b/lib/server/request/pengueno.ts
@@ -0,0 +1,44 @@
+import { BaseRequest, ITraceable, ServerTrace } from '@emprespresso/pengueno';
+
+const greetings = ['hewwo :D', 'hiya cutie', 'boop!', 'sending virtual hugs!', 'stay pawsitive'];
+const penguenoGreeting = () => greetings[Math.floor(Math.random() * greetings.length)];
+
+export class PenguenoRequest {
+ private constructor(
+ public readonly req: BaseRequest,
+ private readonly id: string,
+ private readonly at: Date,
+ ) {}
+
+ public elapsedTimeMs(after = () => Date.now()): number {
+ return after() - this.at.getTime();
+ }
+
+ public getResponseHeaders(): Record<string, string> {
+ const RequestId = this.id;
+ const RequestReceivedUnix = this.at.getTime();
+ const RequestHandleUnix = Date.now();
+ const DeltaUnix = this.elapsedTimeMs(() => RequestHandleUnix);
+ const Hai = penguenoGreeting();
+
+ return Object.entries({
+ RequestId,
+ RequestReceivedUnix,
+ RequestHandleUnix,
+ DeltaUnix,
+ Hai,
+ }).reduce((acc, [key, val]) => ({ ...acc, [key]: val!.toString() }), {});
+ }
+
+ public static from(request: ITraceable<BaseRequest, ServerTrace>): ITraceable<PenguenoRequest, ServerTrace> {
+ const id = crypto.randomUUID();
+ return request.bimap((tRequest) => {
+ const request = tRequest.get();
+ const url = new URL(request.url);
+ const { pathname } = url;
+ const trace = `RequestId = ${id}, Method = ${request.method}, Path = ${pathname}`;
+
+ return { item: new PenguenoRequest(request, id, new Date()), trace };
+ });
+ }
+}
diff --git a/lib/server/response/index.ts b/lib/server/response/index.ts
new file mode 100644
index 0000000..2900f6f
--- /dev/null
+++ b/lib/server/response/index.ts
@@ -0,0 +1,17 @@
+import { Body } from '@emprespresso/pengueno';
+
+export interface BaseResponse {
+ status: number;
+ statusText: string;
+ headers: Record<string, string>;
+
+ body(): Body;
+}
+
+export interface ResponseOpts {
+ status: number;
+ statusText?: string;
+ headers?: Record<string, string>;
+}
+
+export * from './pengueno';
diff --git a/lib/server/response/pengueno.ts b/lib/server/response/pengueno.ts
new file mode 100644
index 0000000..e15d3f1
--- /dev/null
+++ b/lib/server/response/pengueno.ts
@@ -0,0 +1,81 @@
+import {
+ BaseResponse,
+ Body,
+ HttpStatusCodes,
+ isEither,
+ ITraceable,
+ Metric,
+ Optional,
+ PenguenoRequest,
+ ResponseOpts,
+ ServerTrace,
+} from '@emprespresso/pengueno';
+
+const getHeaders = (req: PenguenoRequest, extraHeaders: Record<string, string>) => {
+ const optHeaders = {
+ ...req.getResponseHeaders(),
+ ...extraHeaders,
+ };
+ optHeaders['Content-Type'] = (optHeaders['Content-Type'] ?? 'text/plain') + '; charset=utf-8';
+ return optHeaders;
+};
+
+const ResponseCodeMetrics = [0, 1, 2, 3, 4, 5].map((x) => Metric.fromName(`response.${x}xx`).asResult());
+export const getResponseMetrics = (status: number, elapsedMs?: number) => {
+ const index = Math.floor(status / 100);
+ return ResponseCodeMetrics.flatMap((metric, i) =>
+ Optional.from(i)
+ .filter((i) => i === index)
+ .map(() => [metric.count.withValue(1.0)])
+ .flatMap((metricValues) =>
+ Optional.from(elapsedMs)
+ .map((ms) => metricValues.concat(metric.time.withValue(ms)))
+ .orSome(() => metricValues),
+ )
+ .orSome(() => [metric.count.withValue(0.0)])
+ .get(),
+ );
+};
+
+export class PenguenoResponse implements BaseResponse {
+ public readonly statusText: string;
+ public readonly status: number;
+ public readonly headers: Record<string, string>;
+
+ constructor(
+ req: ITraceable<PenguenoRequest, ServerTrace>,
+ private readonly _body: Body,
+ opts: ResponseOpts,
+ ) {
+ this.headers = getHeaders(req.get(), opts?.headers ?? {});
+ this.status = opts.status;
+ this.statusText = opts.statusText ?? HttpStatusCodes[this.status]!;
+
+ req.trace.trace(getResponseMetrics(opts.status, req.get().elapsedTimeMs()));
+ }
+
+ public body() {
+ return this._body;
+ }
+}
+
+type Jsonable = any;
+export class JsonResponse extends PenguenoResponse {
+ constructor(req: ITraceable<PenguenoRequest, ServerTrace>, e: Jsonable, _opts: ResponseOpts) {
+ const opts = { ..._opts, headers: { ..._opts.headers, 'Content-Type': 'application/json' } };
+ if (isEither<Jsonable, Jsonable>(e)) {
+ super(
+ req,
+ JSON.stringify(
+ e.fold(
+ (error) => ({ error, ok: undefined }),
+ (ok) => ({ ok }),
+ ),
+ ),
+ opts,
+ );
+ return;
+ }
+ super(req, JSON.stringify(Math.floor(opts.status / 100) > 4 ? { error: e } : { ok: e }), opts);
+ }
+}