diff --git a/docs/backend/llm-module-structure.md b/docs/backend/llm-module-structure.md new file mode 100644 index 00000000..feeeeef4 --- /dev/null +++ b/docs/backend/llm-module-structure.md @@ -0,0 +1,190 @@ +# LLM Module Structure (Refactor Runtime) + +This document describes the current backend structure under `server/src/modules/llm`, how execution/session state works, and how the provider abstraction is designed. + +## High-Level Layout + +```text +server/src/modules/llm/ + llm.routes.ts + llm.registry.ts + providers/ + provider.interface.ts + abstract.provider.ts + base-sdk.provider.ts + base-cli.provider.ts + claude.provider.ts + codex.provider.ts + cursor.provider.ts + gemini.provider.ts + services/ + llm.service.ts + sessions.service.ts + sessions-watcher.service.ts + messages-unifier.service.ts + assets.service.ts + mcp.service.ts + skills.service.ts + session-indexers/ + session-indexer.interface.ts + session-indexer.utils.ts + claude.session-indexer.ts + codex.session-indexer.ts + cursor.session-indexer.ts + gemini.session-indexer.ts + index.ts + tests/ + llm-unifier.providers.test.ts + llm-unifier.sessions.test.ts + llm-unifier.images.test.ts + llm-unifier.mcp.test.ts + llm-unifier.skills.test.ts + llm-unifier.messages.test.ts +``` + +## Responsibilities By File Group + +- `llm.routes.ts` + - HTTP API for provider runtime sessions (start/resume/stop/model/thinking), normalized session/history messages, assets upload, MCP config/probe, skills listing, indexed session CRUD/sync. +- `llm.registry.ts` + - Singleton provider registry. Instantiates one provider class per provider id. +- `providers/*` + - Runtime execution and live event collection. + - SDK family (`BaseSdkProvider`) for Claude/Codex. + - CLI family (`BaseCliProvider`) for Cursor/Gemini. +- `services/llm.service.ts` + - Input validation + capability gating + facade over provider registry. +- `services/sessions.service.ts` + - DB-backed indexed sessions and history file parsing. + - Returns normalized message history via `messages-unifier.service.ts`. +- `services/sessions-watcher.service.ts` + - `chokidar` watchers for provider artifact folders. + - On filesystem update, triggers `synchronizeProviderFile(provider, filePath)`. +- `services/messages-unifier.service.ts` + - Provider-specific raw event/history -> unified message contract for frontend. +- `services/assets.service.ts` + - Stores uploaded images in `.cloudcli/assets`. +- `services/mcp.service.ts` + - Unified MCP CRUD/probe across provider-native config formats/scopes/transports. +- `services/skills.service.ts` + - Provider-specific skill directory discovery and metadata extraction. +- `session-indexers/*` + - Scans provider artifacts from disk and upserts indexed sessions into `sessions` DB table. + +## Runtime Flow (Provider Sessions) + +1. `POST /api/llm/providers/:provider/sessions/start` hits `llm.routes.ts`. +2. Route calls `llmService.startSession(...)`. +3. `llm.service.ts` validates payload and capability constraints. +4. `llm.registry.ts` resolves provider instance. +5. Provider (`BaseSdkProvider` or `BaseCliProvider`) creates an in-memory session record and starts execution. +6. Stream/process output is appended as in-memory `ProviderSessionEvent[]`. +7. Route can either: + - return `202` immediately with snapshot, or + - await completion via `waitForSession`. +8. Snapshots are enriched with unified `messages` via `llmMessagesUnifier.normalizeSessionEvents(...)`. + +## Indexed History Flow (Disk/DB) + +1. Watcher or manual sync scans provider folders. +2. Provider-specific indexer extracts minimal metadata and upserts `sessionsDb`. +3. History endpoints (`/sessions/:sessionId/history`, `/sessions/:sessionId/messages`) read transcript path from DB. +4. JSON/JSONL is parsed and transformed via `llmMessagesUnifier.normalizeHistoryEntries(...)`. + +## Interface + Abstract + Base-Class Design + +### `IProvider` (interface) +`providers/provider.interface.ts` + +- Consumer contract used by registry/service layer. +- Exposes: + - `launchSession`, `resumeSession`, `stopSession`, `waitForSession` + - `setSessionModel`, `setSessionThinkingMode` + - `getSession`, `listSessions` + - `listModels` +- Exposes `capabilities` so callers can gate unsupported features before calling provider-specific logic. + +### `AbstractProvider` (abstract class) +`providers/abstract.provider.ts` + +- Shared lifecycle state and rules: + - `sessions: Map` + - `sessionPreferences: Map` +- Implements: + - in-memory session reads (`getSession`, `listSessions`, `waitForSession`) + - stop handling + session status events + - model/thinking updates with capability checks + - event ring-buffer logic (`MAX_EVENT_BUFFER_SIZE`) +- Leaves provider execution specifics abstract (`listModels`, `launchSession`, `resumeSession`). + +### `BaseSdkProvider` and `BaseCliProvider` + +- `BaseSdkProvider` + - shared async iterable stream consumption. + - handles completion/error transitions and completion system event emission. +- `BaseCliProvider` + - shared child-process spawn + stdout/stderr line accumulation + JSON line parsing. + - graceful stop (`SIGTERM` then `SIGKILL`) and completion/error transitions. + +### Concrete provider classes + +- `ClaudeProvider` (SDK) + - uses `@anthropic-ai/claude-agent-sdk`. + - supports runtime permission requests and emits permission events. + - image payload support via base64 content blocks. +- `CodexProvider` (SDK) + - dynamic import of `@openai/codex-sdk`. + - supports text + `local_image` prompt items. +- `CursorProvider` (CLI) + - `cursor-agent` invocation builder + model list parsing. +- `GeminiProvider` (CLI) + - `gemini` invocation builder + curated model catalog. + +## In-Memory Session Setup: How It Works + +The in-memory part is inside `AbstractProvider` + base classes: + +- Session record is created at launch/resume in memory (`Map`). +- Events are appended in real-time while stream/process runs. +- Snapshot endpoints read this map directly (`/providers/:provider/sessions...`). +- Stop/wait/model/thinking controls operate on this same in-memory handle. +- Completed sessions currently remain in map (bounded event history per session, but no map eviction). + +Key characteristics: + +- Process-local only (not shared across instances). +- Lost on server restart. +- Good for immediate live control and progress. +- Not the source of truth for historical transcripts (disk/DB is). + +## Is In-Memory Session State Necessary, Or Useless? + +Short answer: **not useless**, but **not sufficient as a durable architecture**. + +### Why it is necessary in the current design + +- You need live handles for: + - `stopSession` (abort process/stream now), + - `waitForSession`, + - real-time event buffering for immediate API responses. +- These are runtime concerns and cannot be satisfied by session-index DB rows alone. + +### Where it is weak + +- No eviction/pruning for completed session map entries. +- No persistence across restart. +- No cross-instance coordination (if horizontally scaled, only the owning instance can control that session). + +### Practical conclusion + +- Keep in-memory runtime state for **active execution control**. +- Treat DB/indexed history as the durable read model. +- If you need reliability across restarts/instances, move execution ownership to a durable worker/orchestrator and store live session metadata in a shared store. + +## Suggested Hardening (Incremental) + +1. Add session map eviction policy (TTL/LRU for completed/failed/stopped sessions). +2. Add ownership metadata (`instanceId`) if multiple backend instances will run. +3. Add explicit `activeSessions` metric endpoint. +4. Optionally persist minimal runtime state (status transitions + timestamps) to DB for auditability. + diff --git a/server/src/modules/codex/codex.routes.js b/server/src/modules/codex/codex.routes.js index dfe3f40f..4e6dd1c7 100644 --- a/server/src/modules/codex/codex.routes.js +++ b/server/src/modules/codex/codex.routes.js @@ -6,7 +6,7 @@ import os from 'os'; import TOML from '@iarna/toml'; import { getCodexSessions } from '../../../projects.js'; import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import { llmSessionsService } from '@/modules/llm/sessions.service.js'; +import { llmSessionsService } from '@/modules/llm/services/sessions.service.js'; const router = express.Router(); diff --git a/server/src/modules/gemini/gemini.routes.js b/server/src/modules/gemini/gemini.routes.js index 5bf30750..d7c99018 100644 --- a/server/src/modules/gemini/gemini.routes.js +++ b/server/src/modules/gemini/gemini.routes.js @@ -1,6 +1,6 @@ import express from 'express'; import sessionManager from '../../../sessionManager.js'; -import { llmSessionsService } from '@/modules/llm/sessions.service.js'; +import { llmSessionsService } from '@/modules/llm/services/sessions.service.js'; const router = express.Router(); diff --git a/server/src/modules/llm/services/sessions-watcher.service.ts b/server/src/modules/llm/services/sessions-watcher.service.ts index 2364afe5..178f5d67 100644 --- a/server/src/modules/llm/services/sessions-watcher.service.ts +++ b/server/src/modules/llm/services/sessions-watcher.service.ts @@ -53,11 +53,15 @@ async function onUpdate( filePath: string, provider: LLMProvider, ): Promise { + if (!isWatcherTargetFile(provider, filePath)) { + return; + } + try { - const result = await llmSessionsService.synchronizeProvider(provider, { fullRescan: true }); + const result = await llmSessionsService.synchronizeProviderFile(provider, filePath); logger.info(`LLM watcher sync complete for provider "${provider}" after ${eventType}`, { filePath, - processed: result.processed, + indexed: result.indexed, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -69,6 +73,17 @@ async function onUpdate( } } +/** + * Filters watcher events to provider-specific transcript artifact file types. + */ +function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean { + if (provider === 'gemini') { + return filePath.endsWith('.json'); + } + + return filePath.endsWith('.jsonl'); +} + /** * Initializes LLM session watchers and performs an initial index sync. */ diff --git a/server/src/modules/llm/services/sessions.service.ts b/server/src/modules/llm/services/sessions.service.ts index 14b55c81..9072bd32 100644 --- a/server/src/modules/llm/services/sessions.service.ts +++ b/server/src/modules/llm/services/sessions.service.ts @@ -145,12 +145,12 @@ export const llmSessionsService = { }, /** - * Runs one provider indexer and updates `scan_state.last_scanned_at`. + * Indexes one provider artifact file without running a full provider rescan. */ - async synchronizeProvider( + async synchronizeProviderFile( provider: LLMProvider, - options: { fullRescan?: boolean } = {}, - ): Promise<{ provider: LLMProvider; processed: number }> { + filePath: string, + ): Promise<{ provider: LLMProvider; indexed: boolean }> { const indexer = sessionIndexers.find((entry) => entry.provider === provider); if (!indexer) { throw new AppError(`No session indexer registered for provider "${provider}".`, { @@ -159,11 +159,12 @@ export const llmSessionsService = { }); } - const lastScanAt = options.fullRescan ? null : scanStateDb.getLastScannedAt(); - const processed = await indexer.synchronize(lastScanAt); - scanStateDb.updateLastScannedAt(); + if (!indexer.synchronizeFile) { + return { provider, indexed: false }; + } - return { provider, processed }; + const indexed = await indexer.synchronizeFile(filePath); + return { provider, indexed }; }, updateSessionCustomName(sessionId: string, sessionCustomName: string): void { diff --git a/server/src/modules/llm/session-indexers/claude.session-indexer.ts b/server/src/modules/llm/session-indexers/claude.session-indexer.ts index 3a8a680c..e7fb6ed4 100644 --- a/server/src/modules/llm/session-indexers/claude.session-indexer.ts +++ b/server/src/modules/llm/session-indexers/claude.session-indexer.ts @@ -22,15 +22,15 @@ type ParsedSession = { */ export class ClaudeSessionIndexer implements ISessionIndexer { readonly provider = 'claude' as const; + private readonly claudeHome = path.join(os.homedir(), '.claude'); /** * Scans ~/.claude projects and upserts discovered sessions into DB. */ async synchronize(lastScanAt: Date | null): Promise { - const claudeHome = path.join(os.homedir(), '.claude'); - const nameMap = await buildLookupMap(path.join(claudeHome, 'history.jsonl'), 'sessionId', 'display'); + const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display'); const files = await findFilesRecursivelyCreatedAfter( - path.join(claudeHome, 'projects'), + path.join(this.claudeHome, 'projects'), '.jsonl', lastScanAt, ); @@ -58,6 +58,34 @@ export class ClaudeSessionIndexer implements ISessionIndexer { return processed; } + /** + * Parses and upserts one Claude session JSONL file. + */ + async synchronizeFile(filePath: string): Promise { + if (!filePath.endsWith('.jsonl')) { + return false; + } + + const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display'); + const parsed = await this.processSessionFile(filePath, nameMap); + if (!parsed) { + return false; + } + + const timestamps = await readFileTimestamps(filePath); + sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.workspacePath, + parsed.sessionName, + timestamps.createdAt, + timestamps.updatedAt, + filePath, + ); + + return true; + } + /** * Extracts session metadata from one Claude JSONL session file. */ diff --git a/server/src/modules/llm/session-indexers/codex.session-indexer.ts b/server/src/modules/llm/session-indexers/codex.session-indexer.ts index 65558103..20be3d94 100644 --- a/server/src/modules/llm/session-indexers/codex.session-indexer.ts +++ b/server/src/modules/llm/session-indexers/codex.session-indexer.ts @@ -22,15 +22,15 @@ type ParsedSession = { */ export class CodexSessionIndexer implements ISessionIndexer { readonly provider = 'codex' as const; + private readonly codexHome = path.join(os.homedir(), '.codex'); /** * Scans ~/.codex sessions and upserts discovered sessions into DB. */ async synchronize(lastScanAt: Date | null): Promise { - const codexHome = path.join(os.homedir(), '.codex'); - const nameMap = await buildLookupMap(path.join(codexHome, 'session_index.jsonl'), 'id', 'thread_name'); + const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name'); const files = await findFilesRecursivelyCreatedAfter( - path.join(codexHome, 'sessions'), + path.join(this.codexHome, 'sessions'), '.jsonl', lastScanAt, ); @@ -58,6 +58,34 @@ export class CodexSessionIndexer implements ISessionIndexer { return processed; } + /** + * Parses and upserts one Codex session JSONL file. + */ + async synchronizeFile(filePath: string): Promise { + if (!filePath.endsWith('.jsonl')) { + return false; + } + + const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name'); + const parsed = await this.processSessionFile(filePath, nameMap); + if (!parsed) { + return false; + } + + const timestamps = await readFileTimestamps(filePath); + sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.workspacePath, + parsed.sessionName, + timestamps.createdAt, + timestamps.updatedAt, + filePath, + ); + + return true; + } + /** * Extracts session metadata from one Codex JSONL session file. */ diff --git a/server/src/modules/llm/session-indexers/cursor.session-indexer.ts b/server/src/modules/llm/session-indexers/cursor.session-indexer.ts index a2cdf968..90d03c73 100644 --- a/server/src/modules/llm/session-indexers/cursor.session-indexer.ts +++ b/server/src/modules/llm/session-indexers/cursor.session-indexer.ts @@ -25,13 +25,13 @@ type ParsedSession = { */ export class CursorSessionIndexer implements ISessionIndexer { readonly provider = 'cursor' as const; + private readonly cursorHome = path.join(os.homedir(), '.cursor'); /** * Scans Cursor chats and upserts discovered sessions into DB. */ async synchronize(lastScanAt: Date | null): Promise { - const cursorHome = path.join(os.homedir(), '.cursor'); - const projectsDir = path.join(cursorHome, 'projects'); + const projectsDir = path.join(this.cursorHome, 'projects'); const projectEntries = await listDirectoryEntriesSafe(projectsDir); const seenWorkspacePaths = new Set(); @@ -49,7 +49,7 @@ export class CursorSessionIndexer implements ISessionIndexer { seenWorkspacePaths.add(workspacePath); const workspaceHash = this.md5(workspacePath); - const chatsDir = path.join(cursorHome, 'chats', workspaceHash); + const chatsDir = path.join(this.cursorHome, 'chats', workspaceHash); const files = await findFilesRecursivelyCreatedAfter(chatsDir, '.jsonl', lastScanAt); for (const filePath of files) { @@ -75,6 +75,33 @@ export class CursorSessionIndexer implements ISessionIndexer { return processed; } + /** + * Parses and upserts one Cursor session JSONL file. + */ + async synchronizeFile(filePath: string): Promise { + if (!filePath.endsWith('.jsonl')) { + return false; + } + + const parsed = await this.processSessionFile(filePath); + if (!parsed) { + return false; + } + + const timestamps = await readFileTimestamps(filePath); + sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.workspacePath, + parsed.sessionName, + timestamps.createdAt, + timestamps.updatedAt, + filePath, + ); + + return true; + } + /** * Produces the same workspace hash Cursor uses in chat directory names. */ diff --git a/server/src/modules/llm/session-indexers/gemini.session-indexer.ts b/server/src/modules/llm/session-indexers/gemini.session-indexer.ts index 147c2727..7c4abf12 100644 --- a/server/src/modules/llm/session-indexers/gemini.session-indexer.ts +++ b/server/src/modules/llm/session-indexers/gemini.session-indexer.ts @@ -21,19 +21,19 @@ type ParsedSession = { */ export class GeminiSessionIndexer implements ISessionIndexer { readonly provider = 'gemini' as const; + private readonly geminiHome = path.join(os.homedir(), '.gemini'); /** * Scans Gemini session JSON files and upserts discovered sessions into DB. */ async synchronize(lastScanAt: Date | null): Promise { - const geminiHome = path.join(os.homedir(), '.gemini'); const legacySessionFiles = await findFilesRecursivelyCreatedAfter( - path.join(geminiHome, 'sessions'), + path.join(this.geminiHome, 'sessions'), '.json', lastScanAt, ); const tempFiles = await findFilesRecursivelyCreatedAfter( - path.join(geminiHome, 'tmp'), + path.join(this.geminiHome, 'tmp'), '.json', lastScanAt, ); @@ -42,7 +42,7 @@ export class GeminiSessionIndexer implements ISessionIndexer { let processed = 0; for (const filePath of files) { if ( - filePath.startsWith(path.join(geminiHome, 'tmp')) && + filePath.startsWith(path.join(this.geminiHome, 'tmp')) && !filePath.includes(`${path.sep}chats${path.sep}`) ) { continue; @@ -69,6 +69,40 @@ export class GeminiSessionIndexer implements ISessionIndexer { return processed; } + /** + * Parses and upserts one Gemini session JSON file. + */ + async synchronizeFile(filePath: string): Promise { + if (!filePath.endsWith('.json')) { + return false; + } + + if ( + filePath.startsWith(path.join(this.geminiHome, 'tmp')) && + !filePath.includes(`${path.sep}chats${path.sep}`) + ) { + return false; + } + + const parsed = await this.processSessionFile(filePath); + if (!parsed) { + return false; + } + + const timestamps = await readFileTimestamps(filePath); + sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.workspacePath, + parsed.sessionName, + timestamps.createdAt, + timestamps.updatedAt, + filePath, + ); + + return true; + } + /** * Extracts session metadata from one Gemini JSON artifact. */ diff --git a/server/src/modules/llm/session-indexers/session-indexer.interface.ts b/server/src/modules/llm/session-indexers/session-indexer.interface.ts index 1b6a6d20..5fba011b 100644 --- a/server/src/modules/llm/session-indexers/session-indexer.interface.ts +++ b/server/src/modules/llm/session-indexers/session-indexer.interface.ts @@ -10,4 +10,9 @@ export interface ISessionIndexer { * Scans provider session artifacts and upserts discovered sessions into DB. */ synchronize(lastScanAt: Date | null): Promise; + + /** + * Parses and upserts one provider artifact file without running a full directory scan. + */ + synchronizeFile?(filePath: string): Promise; } diff --git a/server/src/modules/llm/session-indexers/session-indexer.utils.ts b/server/src/modules/llm/session-indexers/session-indexer.utils.ts index 4767a88a..0ed7213e 100644 --- a/server/src/modules/llm/session-indexers/session-indexer.utils.ts +++ b/server/src/modules/llm/session-indexers/session-indexer.utils.ts @@ -74,6 +74,7 @@ export async function findFilesRecursivelyCreatedAfter( fileList: string[] = [], ): Promise { try { + console.log("HEY THERE!") const entries = await fsp.readdir(rootDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(rootDir, entry.name); diff --git a/server/src/modules/llm/tests/llm-unifier.sessions.test.ts b/server/src/modules/llm/tests/llm-unifier.sessions.test.ts index 60d2698e..7ff2fd5e 100644 --- a/server/src/modules/llm/tests/llm-unifier.sessions.test.ts +++ b/server/src/modules/llm/tests/llm-unifier.sessions.test.ts @@ -66,36 +66,32 @@ test('llmSessionsService.synchronizeSessions aggregates processed counts and fai } }); -// This test covers provider-specific sync behavior for both incremental and full-rescan modes. -test('llmSessionsService.synchronizeProvider honors fullRescan option', { concurrency: false }, async () => { - const observedScanDates: Array = []; - const restoreScanDate = patchMethod(scanStateDb, 'getLastScannedAt', () => new Date('2026-04-02T00:00:00.000Z')); - const restoreUpdateScanDate = patchMethod(scanStateDb, 'updateLastScannedAt', () => {}); +// This test covers single-file indexing delegation used by the watcher (no full provider rescan). +test('llmSessionsService.synchronizeProviderFile delegates to provider indexer file sync', { concurrency: false }, async () => { + let synchronizeCalls = 0; + let synchronizeFilePath: string | null = null; const restoreIndexers = patchIndexers([ { - provider: 'cursor', - async synchronize(lastScanAt) { - observedScanDates.push(lastScanAt); - return 7; + provider: 'claude', + async synchronize() { + synchronizeCalls += 1; + return 0; + }, + async synchronizeFile(filePath: string) { + synchronizeFilePath = filePath; + return true; }, }, ]); try { - const incremental = await llmSessionsService.synchronizeProvider('cursor'); - const fullRescan = await llmSessionsService.synchronizeProvider('cursor', { fullRescan: true }); - - assert.equal(incremental.provider, 'cursor'); - assert.equal(incremental.processed, 7); - assert.equal(fullRescan.provider, 'cursor'); - assert.equal(fullRescan.processed, 7); - assert.equal(observedScanDates.length, 2); - assert.ok(observedScanDates[0] instanceof Date); - assert.equal(observedScanDates[1], null); + const result = await llmSessionsService.synchronizeProviderFile('claude', '/tmp/claude-session.jsonl'); + assert.equal(result.provider, 'claude'); + assert.equal(result.indexed, true); + assert.equal(synchronizeFilePath, '/tmp/claude-session.jsonl'); + assert.equal(synchronizeCalls, 0); } finally { restoreIndexers(); - restoreUpdateScanDate(); - restoreScanDate(); } }); diff --git a/server/src/modules/projects/projects.inline.routes.js b/server/src/modules/projects/projects.inline.routes.js index 336ac110..e2fbbbbf 100644 --- a/server/src/modules/projects/projects.inline.routes.js +++ b/server/src/modules/projects/projects.inline.routes.js @@ -11,7 +11,7 @@ import { } from '../../../projects.js'; import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js'; -import { llmSessionsService } from '@/modules/llm/sessions.service.js'; +import { llmSessionsService } from '@/modules/llm/services/sessions.service.js'; import { authenticateToken } from '../auth/auth.middleware.js'; import { getWorkspaceNameFromPath, WORKSPACES_ROOT, validateWorkspacePath } from './projects.utils.js'; diff --git a/server/src/shared/http/api-response.ts b/server/src/shared/http/api-response.ts index e3cab7b6..ecfbd23e 100644 --- a/server/src/shared/http/api-response.ts +++ b/server/src/shared/http/api-response.ts @@ -4,13 +4,6 @@ import type { ApiSuccessShape, } from '@/shared/types/http.js'; -export function createApiMeta(requestId?: string, startedAt?: string): ApiMeta { - return { - requestId, - startedAt, - }; -} - export function createApiSuccessResponse( data: TData, meta?: ApiMeta diff --git a/server/src/shared/http/error-handler.ts b/server/src/shared/http/error-handler.ts deleted file mode 100644 index 97a95de1..00000000 --- a/server/src/shared/http/error-handler.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { NextFunction, Request, Response } from 'express'; - -import { createApiErrorResponse, createApiMeta } from '@/shared/http/api-response.js'; -import { getRequestContext } from '@/shared/http/request-context.js'; -import { AppError } from '@/shared/utils/app-error.js'; -import { logger } from '@/shared/utils/logger.js'; - -export function errorHandler( - error: Error, - req: Request, - res: Response, - _next: NextFunction -): void { - const appError = error instanceof AppError ? error : new AppError(error.message); - const context = getRequestContext(req); - const payload = createApiErrorResponse( - appError.code, - appError.message, - createApiMeta(context?.requestId, context?.startedAt), - appError.details - ); - - logger.error(appError.message, { - code: appError.code, - statusCode: appError.statusCode, - requestId: context?.requestId, - }); - - res.status(appError.statusCode).json(payload); -} diff --git a/server/src/shared/http/not-found-handler.ts b/server/src/shared/http/not-found-handler.ts deleted file mode 100644 index 09ed7876..00000000 --- a/server/src/shared/http/not-found-handler.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Request, Response } from 'express'; - -import { createApiErrorResponse, createApiMeta } from '@/shared/http/api-response.js'; -import { getRequestContext } from '@/shared/http/request-context.js'; - -export function notFoundHandler(req: Request, res: Response): void { - const context = getRequestContext(req); - const payload = createApiErrorResponse( - 'NOT_FOUND', - `Route not found: ${req.originalUrl}`, - createApiMeta(context?.requestId, context?.startedAt) - ); - - res.status(404).json(payload); -} diff --git a/server/src/shared/http/request-context.ts b/server/src/shared/http/request-context.ts deleted file mode 100644 index 28888ada..00000000 --- a/server/src/shared/http/request-context.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { randomUUID } from 'crypto'; -import type { NextFunction, Request, Response } from 'express'; - -import type { RequestContext } from '@/shared/types/http.js'; - -type RequestWithContext = Request & { - context?: RequestContext; -}; - -export function getRequestContext(req: Request): RequestContext | undefined { - return (req as RequestWithContext).context; -} - -// give every request a context with a unique ID and timestamp for tracking purposes -export function requestContextMiddleware(req: Request, res: Response, next: NextFunction): void { - const requestId = randomUUID(); - const startedAt = new Date().toISOString(); - const context: RequestContext = { - requestId, - startedAt, - }; - - (req as RequestWithContext).context = context; - (res.locals as Record).requestId = requestId; - - next(); -}