release: WRNexusJS 0.4.0
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user