import { randomUUID } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; import type { ApiErrorShape, ApiSuccessShape, AppErrorOptions, NormalizedMessage, } from '@/shared/types.js'; type NormalizedMessageInput = { kind: NormalizedMessage['kind']; provider: NormalizedMessage['provider']; id?: string | null; sessionId?: string | null; timestamp?: string | null; } & Record; export function createApiSuccessResponse( data: TData, ): ApiSuccessShape { return { success: true, data, }; } export function createApiErrorResponse( code: string, message: string, details?: unknown ): ApiErrorShape { return { success: false, error: { code, message, details, } }; } export function asyncHandler( handler: (req: Request, res: Response, next: NextFunction) => Promise ): RequestHandler { return (req, res, next) => { void Promise.resolve(handler(req, res, next)).catch(next); }; } // --------- Global app error class for consistent error handling across the server --------- export class AppError extends Error { readonly code: string; readonly statusCode: number; readonly details?: unknown; constructor(message: string, options: AppErrorOptions = {}) { super(message); this.name = 'AppError'; this.code = options.code ?? 'INTERNAL_ERROR'; this.statusCode = options.statusCode ?? 500; this.details = options.details; } } // ------------------------------------------------------------------------------------------- // ------------------------ Normalized provider message helpers ------------------------ /** * Generates a stable unique id for normalized provider messages. */ export function generateMessageId(prefix = 'msg'): string { return `${prefix}_${randomUUID()}`; } /** * Creates a normalized provider message and fills the shared envelope fields. * * Provider adapters and live SDK handlers pass through provider-specific fields, * while this helper guarantees every emitted event has an id, session id, * timestamp, and provider marker. */ export function createNormalizedMessage(fields: NormalizedMessageInput): NormalizedMessage { return { ...fields, id: fields.id || generateMessageId(fields.kind), sessionId: fields.sessionId || '', timestamp: fields.timestamp || new Date().toISOString(), provider: fields.provider, }; } // ------------------------------------------------------------------------------------------- // ------------------------ The following are mainly for provider MCP runtimes ------------------------ /** * Safely narrows an unknown value to a plain object record. * * This deliberately rejects arrays, `null`, and primitive values so callers can * treat the returned value as a JSON-style object map without repeating the same * defensive shape checks at every config read site. */ export const readObjectRecord = (value: unknown): Record | null => { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } return value as Record; }; /** * Reads an optional string from unknown input and normalizes empty or whitespace-only * values to `undefined`. * * This is useful when parsing config files where a field may be missing, present * with the wrong type, or present as an empty string that should be treated as * "not configured". */ export const readOptionalString = (value: unknown): string | undefined => { if (typeof value !== 'string') { return undefined; } const normalized = value.trim(); return normalized.length > 0 ? normalized : undefined; }; /** * Reads an optional string array from unknown input. * * Non-array values are ignored, and any array entries that are not strings are * filtered out. This lets provider config readers consume loosely shaped JSON/TOML * data without failing on incidental invalid members. */ export const readStringArray = (value: unknown): string[] | undefined => { if (!Array.isArray(value)) { return undefined; } return value.filter((entry): entry is string => typeof entry === 'string'); }; /** * Reads an optional string-to-string map from unknown input. * * The function first ensures the source value is a plain object, then keeps only * keys whose values are strings. If no valid entries remain, it returns `undefined` * so callers can distinguish "no usable map" from an empty object that was * intentionally authored downstream. */ export const readStringRecord = (value: unknown): Record | undefined => { const record = readObjectRecord(value); if (!record) { return undefined; } const normalized: Record = {}; for (const [key, entry] of Object.entries(record)) { if (typeof entry === 'string') { normalized[key] = entry; } } return Object.keys(normalized).length > 0 ? normalized : undefined; }; /** * Reads a JSON config file and guarantees a plain object result. * * Missing files are treated as an empty config object so provider-specific MCP * readers can operate against first-run environments without special-case file * existence checks. If the file exists but contains invalid JSON, the parse error * is preserved and rethrown. */ export const readJsonConfig = async (filePath: string): Promise> => { try { const content = await readFile(filePath, 'utf8'); const parsed = JSON.parse(content) as Record; return readObjectRecord(parsed) ?? {}; } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === 'ENOENT') { return {}; } throw error; } }; /** * Writes a JSON config file with stable, human-readable formatting. * * The parent directory is created automatically so callers can persist config into * provider-specific folders without pre-creating the directory tree. Output always * ends with a trailing newline to keep the file diff-friendly. */ export const writeJsonConfig = async (filePath: string, data: Record): Promise => { await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); }; // -------------------------------------------------------------------------------------------