196 lines
6.0 KiB
TypeScript
196 lines
6.0 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
export interface ProblemDetails {
|
|
type: string;
|
|
title: string;
|
|
status: number;
|
|
detail?: string;
|
|
instance?: string;
|
|
code?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface ProblemDetailsInput {
|
|
type?: string;
|
|
title: string;
|
|
status: number;
|
|
detail?: string;
|
|
instance?: string;
|
|
code?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export function problem(details: ProblemDetailsInput, headers?: HeadersInit): Response {
|
|
const body: ProblemDetails = {
|
|
...details,
|
|
type: details.type ?? "about:blank",
|
|
title: details.title,
|
|
status: details.status,
|
|
};
|
|
return Response.json(body, {
|
|
status: body.status,
|
|
headers: {
|
|
"content-type": "application/problem+json; charset=utf-8",
|
|
...Object.fromEntries(new Headers(headers)),
|
|
},
|
|
});
|
|
}
|
|
|
|
export type ServiceToken<T> = string | symbol | { readonly key: symbol; readonly __type?: T };
|
|
export function serviceToken<T>(description: string): ServiceToken<T> {
|
|
return { key: Symbol(description) };
|
|
}
|
|
function tokenKey<T>(token: ServiceToken<T>): string | symbol {
|
|
return typeof token === "object" ? token.key : token;
|
|
}
|
|
|
|
export class ServiceContainer {
|
|
readonly #values = new Map<string | symbol, unknown>();
|
|
constructor(private readonly parent?: ServiceContainer) {}
|
|
set<T>(token: ServiceToken<T>, value: T): this {
|
|
this.#values.set(tokenKey(token), value);
|
|
return this;
|
|
}
|
|
has<T>(token: ServiceToken<T>): boolean {
|
|
return this.#values.has(tokenKey(token)) || !!this.parent?.has(token);
|
|
}
|
|
get<T>(token: ServiceToken<T>): T {
|
|
const key = tokenKey(token);
|
|
if (this.#values.has(key)) return this.#values.get(key) as T;
|
|
if (this.parent) return this.parent.get(token);
|
|
throw new Error(`WRN-SERVICE-NOT-FOUND: ${typeof key === "symbol" ? key.description : key}`);
|
|
}
|
|
tryGet<T>(token: ServiceToken<T>): T | undefined {
|
|
try {
|
|
return this.get(token);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
scope(): ServiceContainer {
|
|
return new ServiceContainer(this);
|
|
}
|
|
}
|
|
|
|
export type LifecyclePhase = "starting" | "started" | "stopping" | "stopped";
|
|
export type LifecycleHandler = (signal: AbortSignal) => void | Promise<void>;
|
|
export class ApplicationLifecycle {
|
|
readonly #handlers = new Map<LifecyclePhase, LifecycleHandler[]>();
|
|
#controller = new AbortController();
|
|
on(phase: LifecyclePhase, handler: LifecycleHandler): () => void {
|
|
const handlers = this.#handlers.get(phase) ?? [];
|
|
handlers.push(handler);
|
|
this.#handlers.set(phase, handlers);
|
|
return () => {
|
|
const index = handlers.indexOf(handler);
|
|
if (index >= 0) handlers.splice(index, 1);
|
|
};
|
|
}
|
|
async run(phase: LifecyclePhase): Promise<void> {
|
|
if (phase === "stopping") this.#controller.abort("application stopping");
|
|
const handlers = this.#handlers.get(phase) ?? [];
|
|
const sequence =
|
|
phase === "stopping" || phase === "stopped" ? [...handlers].reverse() : handlers;
|
|
for (const handler of sequence) await handler(this.#controller.signal);
|
|
}
|
|
get signal(): AbortSignal {
|
|
return this.#controller.signal;
|
|
}
|
|
}
|
|
|
|
export interface HealthCheckResult {
|
|
status: "up" | "down" | "degraded";
|
|
message?: string;
|
|
details?: unknown;
|
|
durationMs?: number;
|
|
}
|
|
export type HealthCheck = () => HealthCheckResult | Promise<HealthCheckResult>;
|
|
export class HealthRegistry {
|
|
readonly #checks = new Map<string, HealthCheck>();
|
|
register(name: string, check: HealthCheck): () => void {
|
|
this.#checks.set(name, check);
|
|
return () => this.#checks.delete(name);
|
|
}
|
|
async check(): Promise<{
|
|
status: "up" | "down" | "degraded";
|
|
checks: Record<string, HealthCheckResult>;
|
|
}> {
|
|
const checks: Record<string, HealthCheckResult> = {};
|
|
for (const [name, check] of this.#checks) {
|
|
const start = performance.now();
|
|
try {
|
|
checks[name] = {
|
|
...(await check()),
|
|
durationMs: Math.round((performance.now() - start) * 100) / 100,
|
|
};
|
|
} catch (error) {
|
|
checks[name] = {
|
|
status: "down",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
durationMs: Math.round((performance.now() - start) * 100) / 100,
|
|
};
|
|
}
|
|
}
|
|
const values = Object.values(checks);
|
|
const status = values.some((item) => item.status === "down")
|
|
? "down"
|
|
: values.some((item) => item.status === "degraded")
|
|
? "degraded"
|
|
: "up";
|
|
return { status, checks };
|
|
}
|
|
}
|
|
|
|
export function requestId(headers: Headers, preferred?: string): string {
|
|
const existing =
|
|
preferred ?? headers.get("x-request-id") ?? headers.get("traceparent")?.split("-")[1];
|
|
return existing && /^[A-Za-z0-9._:-]{8,128}$/.test(existing) ? existing : randomUUID();
|
|
}
|
|
|
|
export interface IdempotencyRecord<T = unknown> {
|
|
key: string;
|
|
value: T;
|
|
expiresAt: number;
|
|
}
|
|
export interface IdempotencyStore<T = unknown> {
|
|
get(key: string): Promise<IdempotencyRecord<T> | null>;
|
|
set(record: IdempotencyRecord<T>): Promise<void>;
|
|
delete(key: string): Promise<void>;
|
|
}
|
|
export function memoryIdempotencyStore<T = unknown>(
|
|
now: () => number = Date.now,
|
|
): IdempotencyStore<T> {
|
|
const records = new Map<string, IdempotencyRecord<T>>();
|
|
return {
|
|
async get(key) {
|
|
const value = records.get(key);
|
|
if (!value) return null;
|
|
if (value.expiresAt <= now()) {
|
|
records.delete(key);
|
|
return null;
|
|
}
|
|
return value;
|
|
},
|
|
async set(record) {
|
|
records.set(record.key, record);
|
|
},
|
|
async delete(key) {
|
|
records.delete(key);
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function withIdempotency<T>(
|
|
store: IdempotencyStore<T>,
|
|
key: string,
|
|
execute: () => Promise<T>,
|
|
ttlMs = 24 * 60 * 60 * 1000,
|
|
): Promise<{ value: T; replayed: boolean }> {
|
|
if (!key.trim()) throw new TypeError("idempotency key cannot be empty");
|
|
const existing = await store.get(key);
|
|
if (existing) return { value: existing.value, replayed: true };
|
|
const value = await execute();
|
|
await store.set({ key, value, expiresAt: Date.now() + ttlMs });
|
|
return { value, replayed: false };
|
|
}
|