Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 155 additions & 79 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,58 @@
import './init-env'; // Must be the first import to ensure env vars are loaded before other modules
import dns from 'node:dns';
import "./init-env"; // Must be the first import to ensure env vars are loaded before other modules
import dns from "node:dns";

// Force IPv4 first to avoid "other side closed" errors with GitHub/OAuth providers on some networks
if (dns.setDefaultResultOrder) {
dns.setDefaultResultOrder('ipv4first');
dns.setDefaultResultOrder("ipv4first");
}

import express, { Application, Request, Response } from 'express';
import path from 'path';
import cors from 'cors';
import { createServer } from 'http';
import { Server } from 'socket.io';
import swaggerUi from 'swagger-ui-express';
import { logger } from './utils/logger';
import { errorHandler } from './middleware/errorHandler';
import { authMiddleware } from './middleware/auth';
import { startWorker } from './queue/worker';
import { i18nMiddleware } from './config/i18n';
import express, { Application, Request, Response } from "express";
import path from "path";
import cors from "cors";
import { createServer } from "http";
import { Server } from "socket.io";
import swaggerUi from "swagger-ui-express";
import { logger } from "./utils/logger";
import { errorHandler } from "./middleware/errorHandler";
import { authMiddleware } from "./middleware/auth";
import { startWorker } from "./queue/worker";
import { i18nMiddleware } from "./config/i18n";

// Import routes
import novelRoutes from './routes/novel.routes';
import chapterRoutes from './routes/chapter.routes';
import knowledgeRoutes from './routes/knowledge.routes';
import aiConfigRoutes from './routes/aiConfig.routes';
import taskRoutes from './routes/task.routes';
import chatRoutes from './routes/chat.routes';
import sandboxRoutes from './routes/sandbox.routes';
import novelRoutes from "./routes/novel.routes";
import chapterRoutes from "./routes/chapter.routes";
import knowledgeRoutes from "./routes/knowledge.routes";
import aiConfigRoutes from "./routes/aiConfig.routes";
import taskRoutes from "./routes/task.routes";
import chatRoutes from "./routes/chat.routes";
import sandboxRoutes from "./routes/sandbox.routes";

const app: Application = express();
const httpServer = createServer(app);
// CORS configuration
const allowedOrigins = process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',').map(origin => origin.trim())
: ['http://localhost:8001', 'tauri://localhost', 'http://tauri.localhost'];
const allowedOrigins = process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(",").map((origin) => origin.trim())
: ["http://localhost:8001", "tauri://localhost", "http://tauri.localhost"];

const corsOptions = {
origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => {
// allow requests with no origin (like mobile apps, curl requests, or SSR)
if (!origin) return callback(null, true);

// Check for allowed origins or development mode
if (allowedOrigins.indexOf(origin) !== -1 || process.env.NODE_ENV === 'development') {
if (
allowedOrigins.indexOf(origin) !== -1 ||
process.env.NODE_ENV === "development"
) {
return callback(null, true);
}

// If we're here, check if we want to be permissive for debugging
if (process.env.CORS_ALLOW_ALL === 'true') {
return callback(null, true);
if (process.env.CORS_ALLOW_ALL === "true") {
return callback(null, true);
}

console.warn(`Blocked by CORS: ${origin}`);
Expand All @@ -55,7 +61,7 @@ const corsOptions = {
credentials: true,
};

import { setIO } from './socket-singleton';
import { setIO } from "./socket-singleton";

const io = new Server(httpServer, {
cors: corsOptions,
Expand All @@ -71,43 +77,52 @@ const PORT = process.env.BACKEND_PORT || 8002;
app.use(cors(corsOptions));

// Trust proxy - Required for secure cookies behind Nginx/Cloudflare
app.set('trust proxy', true); // Trust all proxies to ensure protocol is correctly identified as HTTPS
app.set("trust proxy", true); // Trust all proxies to ensure protocol is correctly identified as HTTPS

// Better-Auth handler - MUST be before body parsers but AFTER CORS
import { auth } from './config/auth.config';
import { toNodeHandler } from 'better-auth/node';

app.use('/api/auth', (req, _res, next) => {
console.log(`[Auth Debug] Incoming request: ${req.method} ${req.originalUrl || req.url}`);
console.log(`[Auth Debug] Headers: ${JSON.stringify({
host: req.headers.host,
'x-forwarded-proto': req.headers['x-forwarded-proto'],
'x-forwarded-host': req.headers['x-forwarded-host'],
origin: req.headers.origin,
cookieKeys: req.headers.cookie ? req.headers.cookie.split(';').map(c => c.trim().split('=')[0]).join(', ') : 'NONE'
})}`);

import { auth } from "./config/auth.config";
import { toNodeHandler } from "better-auth/node";

app.use("/api/auth", (req, _res, next) => {
console.log(
`[Auth Debug] Incoming request: ${req.method} ${req.originalUrl || req.url}`,
);
console.log(
`[Auth Debug] Headers: ${JSON.stringify({
host: req.headers.host,
"x-forwarded-proto": req.headers["x-forwarded-proto"],
"x-forwarded-host": req.headers["x-forwarded-host"],
origin: req.headers.origin,
cookieKeys: req.headers.cookie
? req.headers.cookie
.split(";")
.map((c) => c.trim().split("=")[0])
.join(", ")
: "NONE",
})}`,
);

// Also log body if it exists (for POST requests)
if (req.method === 'POST') {
if (req.method === "POST") {
// Note: body might not be parsed yet if this is before body-parser
// but better-auth handles its own body if needed, or we might need to parse it
}
next();
});

app.all('/api/auth/*', (req, res) => {
toNodeHandler(auth)(req, res);
app.all("/api/auth/*", (req, res) => {
toNodeHandler(auth)(req, res);
});

// Body parsers - AFTER Better-Auth
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));

// i18n middleware - MUST be before routes
app.use(i18nMiddleware);

// Static files
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
app.use("/uploads", express.static(path.join(__dirname, "../uploads")));

// Request logging
app.use((req, _res, next) => {
Expand All @@ -116,62 +131,123 @@ app.use((req, _res, next) => {
});

// Health check
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
app.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});

// API Documentation
app.use('/api-docs', swaggerUi.serve as any, swaggerUi.setup({
openapi: '3.0.0',
info: {
title: 'Daer Novel API',
version: '1.0.0',
description: 'AI Novel Generation Platform API',
},
}))
app.use(
"/api-docs",
swaggerUi.serve as any,
swaggerUi.setup({
openapi: "3.0.0",
info: {
title: "Daer Novel API",
version: "1.0.0",
description: "AI Novel Generation Platform API",
},
}),
);

// Protected API Routes
app.use('/api/novels', authMiddleware, novelRoutes);
app.use('/api/novels', authMiddleware, taskRoutes); // Task routes for novel generation
app.use('/api/chapters', authMiddleware, chapterRoutes);
app.use('/api', authMiddleware, sandboxRoutes); // Sandbox routes for plot branching
app.use('/api/knowledge', authMiddleware, knowledgeRoutes);
app.use('/api/ai-config', authMiddleware, aiConfigRoutes);
app.use('/api/tasks', authMiddleware, taskRoutes); // Task status routes
app.use('/api/chat', authMiddleware, chatRoutes);
app.use("/api/novels", authMiddleware, novelRoutes);
app.use("/api/novels", authMiddleware, taskRoutes); // Task routes for novel generation
app.use("/api/chapters", authMiddleware, chapterRoutes);
app.use("/api", authMiddleware, sandboxRoutes); // Sandbox routes for plot branching
app.use("/api/knowledge", authMiddleware, knowledgeRoutes);
app.use("/api/ai-config", authMiddleware, aiConfigRoutes);
app.use("/api/tasks", authMiddleware, taskRoutes); // Task status routes
app.use("/api/chat", authMiddleware, chatRoutes);

// WebSocket for real-time task updates
io.on('connection', (socket) => {
io.on("connection", (socket) => {
logger.info(`Client connected: ${socket.id}`);
socket.on('subscribe:task', (taskId: string) => {

socket.on("subscribe:task", (taskId: string) => {
socket.join(`task:${taskId}`);
logger.info(`Client ${socket.id} subscribed to task ${taskId}`);
});
socket.on('unsubscribe:task', (taskId: string) => {

socket.on("unsubscribe:task", (taskId: string) => {
socket.leave(`task:${taskId}`);
});
socket.on('disconnect', () => {

socket.on("disconnect", () => {
logger.info(`Client disconnected: ${socket.id}`);
});
});

// Make io accessible to routes
app.set('io', io);
app.set("io", io);

// Error handling
app.use(errorHandler);

// 404 handler
app.use((_req, res) => {
res.status(404).json({ error: 'Route not found' });
res.status(404).json({ error: "Route not found" });
});


// Start server
startWorker(io);

// Sync stale tasks on startup
async function syncStaleTasks() {
try {
const { db, schema } = await import("./database");
const { eq } = await import("drizzle-orm");

// Find all tasks that are still running or queued
const activeTasks = await db.query.tasks.findMany({
where: (tasks, { inArray }) =>
inArray(tasks.status, ["running", "queued"]),
});

logger.info(`Found ${activeTasks.length} active tasks on startup`);

for (const task of activeTasks) {
// Mark task as failed
await db
.update(schema.tasks)
.set({
status: "failed",
error: "Task interrupted - server restart",
updatedAt: new Date(),
})
.where(eq(schema.tasks.id, task.id));

// If task has a chapter, update chapter status
if (task.chapterId) {
await db
.update(schema.chapters)
.set({ status: "failed", updatedAt: new Date() })
.where(eq(schema.chapters.id, task.chapterId));
}
}

// Also check for chapters stuck in "generating" status
const generatingChapters = await db.query.chapters.findMany({
where: eq(schema.chapters.status, "generating"),
});

for (const chapter of generatingChapters) {
await db
.update(schema.chapters)
.set({ status: "pending", updatedAt: new Date() })
.where(eq(schema.chapters.id, chapter.id));
}

logger.info(
`Synced ${activeTasks.length} tasks and ${generatingChapters.length} chapters`,
);
} catch (error) {
logger.error("Failed to sync stale tasks on startup:", error);
}
}

// Run sync after a short delay to ensure DB is ready
setTimeout(syncStaleTasks, 2000);

httpServer.listen(PORT, () => {
logger.info(`🚀 Server running on port ${PORT}`);
logger.info(`📚 API Docs: http://localhost:${PORT}/api-docs`);
Expand Down
26 changes: 26 additions & 0 deletions apps/backend/src/queue/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ export function startWorker(io: Server) {
.set({ status: "running", updatedAt: new Date() })
.where(eq(schema.tasks.id, taskId));

// Update chapter status to generating for content tasks
if (
chapterId &&
(type === "content" ||
type === "chapter_outline" ||
type === "chapter_detail")
) {
await db
.update(schema.chapters)
.set({ status: "generating", updatedAt: new Date() })
.where(eq(schema.chapters.id, chapterId));
}

// Emit progress update
io.to(`task:${taskId}`).emit("task:progress", {
taskId,
Expand Down Expand Up @@ -422,6 +435,19 @@ export function startWorker(io: Server) {
})
.where(eq(schema.tasks.id, taskId));

// Update chapter status to failed for content tasks
if (
chapterId &&
(type === "content" ||
type === "chapter_outline" ||
type === "chapter_detail")
) {
await db
.update(schema.chapters)
.set({ status: "failed", updatedAt: new Date() })
.where(eq(schema.chapters.id, chapterId));
}

io.to(`task:${taskId}`).emit("task:failed", {
taskId,
error: error.message,
Expand Down
Loading