24 KiB
Backend Architecture
Goal
This document defines the target backend shape for the TypeScript refactor in server/src.
The main constraints are:
- Keep the current API paths stable while the internals move out of
server/index.jsandserver/routes/*.js. - Migrate one route family at a time instead of doing a big-bang rewrite.
- Keep controllers thin and move business logic into services and repositories.
- Keep transport concerns, provider adapters, and domain logic separate.
Current State
As of March 18, 2026:
server/src/bootstrap.tsstarts the TypeScript backend entrypoint.server/src/app.tsstill bridges intoserver/src/runner.ts.server/src/shared/*already contains reusable TypeScript building blocks such as:shared/http/api-response.tsshared/http/async-handler.tsshared/http/error-handler.tsshared/http/request-context.tsshared/utils/app-error.tsshared/database/repositories/*
server/src/modules/*mostly exist as placeholder folders.- The real runtime behavior still mostly lives in:
server/index.jsserver/routes/*.js
docs/backend/endpoint-inventory.mdis the migration checklist for the existing HTTP surface.
Recommended Target Structure
server/
index.js # legacy runtime bridge during migration
start.js # production entrypoint for compiled output
src/
bootstrap.ts # starts the backend process
app.ts # creates the app/server and registers modules
runner.ts # transitional runtime while legacy code still exists
config/
load-env-vars.ts
runtime.ts
realtime/
index.ts # attaches websocket handlers to the HTTP server
chat.gateway.ts # chat websocket behavior
shell.gateway.ts # shell websocket behavior
events.ts # shared broadcast helpers
shared/
auth/
authenticate-token.ts # future TS auth middleware
validate-api-key.ts # future TS API-key middleware
database/
connection.ts
init-db.ts
migrations.ts
schema.ts
types.ts
repositories/
api-keys.ts
app-config.ts
credentials.ts
session-names.ts
users.ts
docs/
openapi.ts
http/
api-response.ts
async-handler.ts
error-handler.ts
not-found-handler.ts
request-context.ts
input/
parse-boolean.ts # future shared query/body parsers
parse-pagination.ts
platform/
index.ts
path.ts
runtime-platform.ts
shell.ts
stream.ts
text.ts
types.ts
types/
app.ts
http.ts
utils/
app-error.ts
logger.ts
modules/
auth/
index.ts
auth.routes.ts
auth.controller.ts
auth.service.ts
auth.schemas.ts
auth.types.ts
cli-auth/
index.ts
cli-auth.routes.ts
cli-auth.controller.ts
cli-auth.service.ts
user/
index.ts
user.routes.ts
user.controller.ts
user.service.ts
user.schemas.ts
user.types.ts
settings/
index.ts
settings.routes.ts
settings.controller.ts
settings.service.ts
settings.schemas.ts
commands/
index.ts
commands.routes.ts
commands.controller.ts
commands.service.ts
projects/
index.ts
projects.routes.ts
projects.controller.ts
projects.service.ts
projects.schemas.ts
workspace-path.policy.ts
files/
index.ts
files.routes.ts
files.controller.ts
files.service.ts
files.schemas.ts
sessions/
index.ts
sessions.routes.ts
sessions.controller.ts
sessions.service.ts
sessions.schemas.ts
git/
index.ts
git.routes.ts
git.controller.ts
git.service.ts
taskmaster/
index.ts
taskmaster.routes.ts
taskmaster.controller.ts
taskmaster.service.ts
agent/
index.ts
agent.routes.ts
agent.controller.ts
agent.service.ts
system/
index.ts
system.routes.ts
system.controller.ts
system.service.ts
spa-fallback.ts
providers/
claude/
index.ts
claude.service.ts
claude-session.service.ts
codex/
index.ts
codex.routes.ts
codex.controller.ts
codex.service.ts
cursor/
index.ts
cursor.routes.ts
cursor.controller.ts
cursor.service.ts
gemini/
index.ts
gemini.routes.ts
gemini.controller.ts
gemini.service.ts
mcp/
index.ts
mcp.routes.ts
mcp.controller.ts
mcp.service.ts
plugins/
index.ts
plugins.routes.ts
plugins.controller.ts
plugins.service.ts
testing/
fixtures/
Why This Shape Works For This Repo
- You already have shared TypeScript HTTP/database utilities in
server/src/shared. - The legacy route surface is already grouped by domain in
server/routes/*.js. - The biggest remaining problem is not "what framework should I use", it is "where should each existing endpoint live and how do I migrate it without breaking paths".
- A module-per-domain structure solves that without forcing a rewrite of all helpers at once.
Route Ownership
Keep the public paths stable. Only move the code behind them.
| Module | Mount path(s) | Current source(s) | Notes |
|---|---|---|---|
auth |
/api/auth |
server/routes/auth.js |
Login, register, logout, auth status, current user |
cli-auth |
/api/cli |
server/routes/cli-auth.js |
Provider CLI auth status endpoints |
user |
/api/user |
server/routes/user.js |
Git identity and onboarding state |
settings |
/api/settings |
server/routes/settings.js |
API keys, credentials, notification preferences, push setup |
commands |
/api/commands |
server/routes/commands.js |
Slash command list, load, execute |
projects |
/api/projects, /api/browse-filesystem, /api/create-folder |
server/routes/projects.js, server/index.js |
Workspace discovery and workspace creation helpers |
files |
/api/projects/:projectName/file, /api/projects/:projectName/files |
server/index.js |
File tree, read/write, upload, rename, delete |
sessions |
/api/projects/:projectName/sessions, /api/sessions/:sessionId/rename, /api/search/conversations |
server/index.js, provider route files |
Project/provider session history |
git |
/api/git |
server/routes/git.js |
Repo operations and git automation |
taskmaster |
/api/taskmaster |
server/routes/taskmaster.js |
TaskMaster setup, PRDs, tasks, parsing |
agent |
/api/agent |
server/routes/agent.js |
External agent orchestration |
system |
/health, /api/system, /api/transcribe, SPA fallback |
server/index.js |
Health, app update, transcription, non-API fallback. Register the SPA fallback last. |
providers/codex |
/api/codex |
server/routes/codex.js |
Config, MCP-adjacent behavior, Codex sessions |
providers/cursor |
/api/cursor |
server/routes/cursor.js |
Config, MCP, Cursor sessions |
providers/gemini |
/api/gemini |
server/routes/gemini.js |
Gemini session endpoints |
providers/mcp |
/api/mcp, /api/mcp-utils |
server/routes/mcp.js, server/routes/mcp-utils.js |
Shared MCP HTTP endpoints |
providers/plugins |
/api/plugins |
server/routes/plugins.js |
Plugin install, enable/disable, assets |
providers/claude |
no standalone HTTP prefix yet | server/index.js, server/claude-sdk.js |
Service/adapters used by chat and agent flows |
realtime |
/ws, /shell |
server/index.js |
Websocket transport, not HTTP routes |
Module Convention
Every feature module should follow the same internal shape:
modules/user/
index.ts # registers the module on the app
user.routes.ts # express.Router + middleware order
user.controller.ts # request/response mapping only
user.service.ts # business rules and orchestration
user.schemas.ts # input parsing and validation
user.types.ts # module-local types only
Responsibilities
-
index.tsOwns the mount path.app.tsshould not know route internals. -
*.routes.tsDeclares routes, middleware order, and child-router mounting. It should not contain SQL, filesystem logic, or long validation blocks. -
*.controller.tsReadsreq, calls schema parsers and services, then writes the response. It should stay thin. -
*.service.tsOwns business logic. It can call repositories, filesystem helpers, platform helpers, provider adapters, and child processes. -
*.schemas.tsValidates/parses request data close to the module. ThrowAppErrorwith400status for bad input. -
*.types.tsHolds module-local types that do not belong inshared/types.
Application Setup
The current server/src/app.ts still starts a transitional runner.
Once the HTTP runtime moves into server/src, the shape should look like this:
import cors from 'cors';
import express from 'express';
import http from 'http';
import { registerAgentModule } from '@/modules/agent/index.js';
import { registerAuthModule } from '@/modules/auth/index.js';
import { registerCliAuthModule } from '@/modules/cli-auth/index.js';
import { registerCommandsModule } from '@/modules/commands/index.js';
import { registerFilesModule } from '@/modules/files/index.js';
import { registerGitModule } from '@/modules/git/index.js';
import { registerProjectsModule } from '@/modules/projects/index.js';
import { registerSessionsModule } from '@/modules/sessions/index.js';
import { registerSettingsModule } from '@/modules/settings/index.js';
import { registerSystemModule } from '@/modules/system/index.js';
import { registerTaskmasterModule } from '@/modules/taskmaster/index.js';
import { registerUserModule } from '@/modules/user/index.js';
import { registerCodexProviderModule } from '@/modules/providers/codex/index.js';
import { registerCursorProviderModule } from '@/modules/providers/cursor/index.js';
import { registerGeminiProviderModule } from '@/modules/providers/gemini/index.js';
import { registerMcpProviderModule } from '@/modules/providers/mcp/index.js';
import { registerPluginsProviderModule } from '@/modules/providers/plugins/index.js';
import { registerSpaFallback } from '@/modules/system/spa-fallback.js';
import { errorHandler } from '@/shared/http/error-handler.js';
import { notFoundHandler } from '@/shared/http/not-found-handler.js';
import { requestContextMiddleware } from '@/shared/http/request-context.js';
import { attachRealtimeHandlers } from '@/realtime/index.js';
export function createHttpRuntime() {
const app = express();
const server = http.createServer(app);
// Global middleware should be registered once, before feature modules.
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(requestContextMiddleware);
// Keep health/system API routes early so diagnostics still work
// even if a later feature module throws during registration.
registerSystemModule(app);
// Public/auth routes first.
registerAuthModule(app);
// Authenticated feature modules after auth.
registerCliAuthModule(app);
registerUserModule(app);
registerSettingsModule(app);
registerCommandsModule(app);
registerProjectsModule(app);
registerFilesModule(app);
registerSessionsModule(app);
registerGitModule(app);
registerTaskmasterModule(app);
registerAgentModule(app);
registerCodexProviderModule(app);
registerCursorProviderModule(app);
registerGeminiProviderModule(app);
registerMcpProviderModule(app);
registerPluginsProviderModule(app);
// Websocket setup should live outside HTTP controllers.
attachRealtimeHandlers(server, app);
// The React fallback must be last or it will swallow real API routes.
registerSpaFallback(app);
app.use(notFoundHandler);
app.use(errorHandler);
return { app, server };
}
Example Module: user
This is a good first migration candidate because the endpoints are small, the domain is clear, and you already have a typed user repository in server/src/shared/database/repositories/users.ts.
Folder
server/src/modules/user/
index.ts
user.routes.ts
user.controller.ts
user.service.ts
user.schemas.ts
user.types.ts
index.ts
import type { Express } from 'express';
import { createUserRouter } from './user.routes.js';
export function registerUserModule(app: Express): void {
// Keep the mount path in one place.
// This makes it easy to see who owns `/api/user`.
app.use('/api/user', createUserRouter());
}
user.routes.ts
import { Router } from 'express';
import { asyncHandler } from '@/shared/http/async-handler.js';
// Day 1: reuse the existing JS middleware while auth is still being migrated.
import { authenticateToken } from '../../../middleware/auth.js';
import {
completeOnboardingController,
getGitConfigController,
getOnboardingStatusController,
updateGitConfigController,
} from './user.controller.js';
export function createUserRouter(): Router {
const router = Router();
// Routes only describe the HTTP surface and middleware order.
router.get('/git-config', authenticateToken, asyncHandler(getGitConfigController));
router.post('/git-config', authenticateToken, asyncHandler(updateGitConfigController));
router.get('/onboarding-status', authenticateToken, asyncHandler(getOnboardingStatusController));
router.post('/complete-onboarding', authenticateToken, asyncHandler(completeOnboardingController));
return router;
}
user.schemas.ts
import { AppError } from '@/shared/utils/app-error.js';
export type UpdateGitConfigInput = {
gitName: string;
gitEmail: string;
};
export function parseUpdateGitConfigInput(body: unknown): UpdateGitConfigInput {
const value = body as Partial<UpdateGitConfigInput> | null;
// Keep input validation close to the module so controllers stay thin.
if (!value?.gitName || !value?.gitEmail) {
throw new AppError('Git name and email are required', {
code: 'VALIDATION_ERROR',
statusCode: 400,
});
}
const gitName = value.gitName.trim();
const gitEmail = value.gitEmail.trim();
if (!gitName) {
throw new AppError('Git name is required', {
code: 'VALIDATION_ERROR',
statusCode: 400,
});
}
if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(gitEmail)) {
throw new AppError('Invalid email format', {
code: 'VALIDATION_ERROR',
statusCode: 400,
});
}
return {
gitName,
gitEmail,
};
}
user.service.ts
import { userDb } from '@/shared/database/repositories/users.js';
import { AppError } from '@/shared/utils/app-error.js';
import type { UpdateGitConfigInput } from './user.schemas.js';
export const userService = {
getGitConfig(userId: number) {
const gitConfig = userDb.getGitConfig(userId);
return {
gitName: gitConfig?.git_name ?? null,
gitEmail: gitConfig?.git_email ?? null,
};
},
updateGitConfig(userId: number, input: UpdateGitConfigInput) {
if (!userDb.getUserById(userId)) {
throw new AppError('User not found', {
code: 'USER_NOT_FOUND',
statusCode: 404,
});
}
// Services own business rules and side effects.
// If you later re-add `git config --global`, it belongs here.
userDb.updateGitConfig(userId, input.gitName, input.gitEmail);
return {
gitName: input.gitName,
gitEmail: input.gitEmail,
};
},
getOnboardingStatus(userId: number) {
return {
hasCompletedOnboarding: userDb.hasCompletedOnboarding(userId),
};
},
completeOnboarding(userId: number) {
userDb.completeOnboarding(userId);
return {
message: 'Onboarding completed successfully',
};
},
};
user.controller.ts
import type { Response } from 'express';
import {
createApiMeta,
createApiSuccessResponse,
} from '@/shared/http/api-response.js';
import { getRequestContext } from '@/shared/http/request-context.js';
import type { AuthenticatedRequest } from '@/shared/types/http.js';
import { AppError } from '@/shared/utils/app-error.js';
import { parseUpdateGitConfigInput } from './user.schemas.js';
import { userService } from './user.service.js';
function getUserId(req: AuthenticatedRequest): number {
const userId = Number(req.user?.id);
if (!Number.isFinite(userId)) {
throw new AppError('Authenticated user is missing', {
code: 'UNAUTHENTICATED',
statusCode: 401,
});
}
return userId;
}
function getMeta(req: AuthenticatedRequest) {
const context = getRequestContext(req);
return createApiMeta(context?.requestId, context?.startedAt);
}
export async function getGitConfigController(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const data = userService.getGitConfig(getUserId(req));
res.json(createApiSuccessResponse(data, getMeta(req)));
}
export async function updateGitConfigController(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const input = parseUpdateGitConfigInput(req.body);
const data = userService.updateGitConfig(getUserId(req), input);
res.json(createApiSuccessResponse(data, getMeta(req)));
}
export async function getOnboardingStatusController(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const data = userService.getOnboardingStatus(getUserId(req));
res.json(createApiSuccessResponse(data, getMeta(req)));
}
export async function completeOnboardingController(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const data = userService.completeOnboarding(getUserId(req));
res.json(createApiSuccessResponse(data, getMeta(req)));
}
Nested Route Example: files
files is the best example of why route ownership and mount paths matter.
The files module should stay separate from projects, but its routes still need access to :projectName.
files/index.ts
import type { Express } from 'express';
import { createFilesRouter } from './files.routes.js';
export function registerFilesModule(app: Express): void {
// The files module owns project-scoped file operations.
app.use('/api/projects/:projectName', createFilesRouter());
}
files.routes.ts
import { Router } from 'express';
import { asyncHandler } from '@/shared/http/async-handler.js';
import { authenticateToken } from '../../../middleware/auth.js';
import {
deleteFilesController,
getFileController,
getFilesController,
putFileController,
} from './files.controller.js';
export function createFilesRouter(): Router {
// `mergeParams: true` lets this router read `req.params.projectName`
// from the mount path declared in `index.ts`.
const router = Router({ mergeParams: true });
router.get('/file', authenticateToken, asyncHandler(getFileController));
router.put('/file', authenticateToken, asyncHandler(putFileController));
router.get('/files', authenticateToken, asyncHandler(getFilesController));
router.delete('/files', authenticateToken, asyncHandler(deleteFilesController));
return router;
}
Why This Separation Is Useful
projectsdecides what a project/workspace is.filesonly operates inside a resolved project.sessionsonly deals with chat/session history.- This prevents one giant
projects.service.tsfrom becoming the new monolith.
Provider Module Pattern
Provider modules should follow the same structure, but they usually own adapter logic as well as HTTP routes.
Example shape for providers/codex:
modules/providers/codex/
index.ts
codex.routes.ts
codex.controller.ts
codex.service.ts
codex.types.ts
codex-mcp.service.ts
codex-sessions.service.ts
Use this rule:
- If the code is specific to one provider, keep it inside that provider folder.
- If the code is shared across multiple providers, move it into
shared/orproviders/mcp/. - If the code is an external-facing orchestration endpoint that can call different providers, keep it in
modules/agent/.
Realtime Structure
Do not leave websocket logic inside app.ts or feature controllers.
Use a dedicated server/src/realtime/ area:
server/src/realtime/
index.ts
chat.gateway.ts
shell.gateway.ts
events.ts
Suggested responsibilities:
-
index.tsAttaches websocket handlers to the HTTP server. -
chat.gateway.tsOwns/wsmessage handling, provider session streaming, reconnect behavior, and approval flows. -
shell.gateway.tsOwns/shellPTY session lifecycle, resize events, and output streaming. -
events.tsOwns broadcast helpers used by HTTP modules such as TaskMaster or project updates.
Boundary Rules
app.tswires modules together. It should not contain feature logic.bootstrap.tsstarts the process. It should not know route details.routesfiles should not contain SQL, filesystem logic, provider process spawning, or long validation blocks.controllersshould not importbetter-sqlite3,fs,node-pty, or raw provider SDKs.servicesown business rules, orchestration, and side effects.repositoriesown SQL only.shared/*is only for code used by two or more modules.providers/*is for provider-specific behavior.agentis orchestration above provider modules.realtime/*owns websocket transport, not HTTP modules.systemowns health/update/fallback endpoints, not project/file/session behavior.
Migration Order
This order keeps risk low:
-
auth,cli-auth,userSmall route files, easy to validate, minimal path behavior. -
settings,commandsStill mostly isolated and already grouped in dedicated route files. -
projects,files,sessionsThese are more coupled and require clear boundaries aroundprojectName. -
git,taskmasterHeavier side effects and more external process behavior. -
providers/*Each provider can move independently once shared platform helpers are stable. -
agentMigrate last because it orchestrates several other pieces. -
realtime/*Move websocket logic after the supporting services are already modular.
Day 1 Migration Rules
- Keep path compatibility first.
- Move one route family at a time.
- It is acceptable to import legacy JS middleware or helpers from a new TS module temporarily.
- When a route family is migrated:
- register the new TS module in
app.ts - remove the old route registration from
server/index.js - keep the response shape unchanged unless you intentionally version it
- register the new TS module in
- Prefer moving business logic into services first, then move transport code.
Practical Setup Checklist
When you create a new module, use this checklist:
- Add the folder under
server/src/modules/<feature>/. - Create
index.ts,*.routes.ts,*.controller.ts,*.service.ts, and*.schemas.ts. - Keep the mount path inside
index.ts. - Reuse
asyncHandler,AppError,createApiSuccessResponse, andrequestContextMiddleware. - Reuse typed repositories from
server/src/shared/database/repositories/*whenever possible. - Register the module in
server/src/app.ts. - Move one legacy route at a time and verify the response shape still matches the frontend.
Package Script Notes
These scripts already match the refactor workflow:
-
npm run server:devUse for backend-only refactor work inserver/src. -
npm run typecheck:serverRun after every backend module migration. -
npm run test:serverUse for shared platform tests and expand it as new TS modules gain tests. -
npm run server:buildConfirms the refactored backend compiles intoserver/dist. -
npm run verify:serverBest validation command before merging backend refactor work.
Summary
The simplest stable rule set for this refactor is:
- keep API paths the same
- move code into domain modules
- keep routes thin
- keep business logic in services
- keep SQL in repositories
- keep provider code inside provider folders
- keep websocket code outside HTTP modules
If you follow that consistently, server/src will become the real backend instead of a second monolith with TypeScript syntax.