150 lines
4.5 KiB
TypeScript
150 lines
4.5 KiB
TypeScript
import Fastify, { FastifyRequest, FastifyReply } from "fastify";
|
|
import cors from "@fastify/cors";
|
|
import fastifyStatic from "@fastify/static";
|
|
import { z } from "zod";
|
|
import { Agent, setGlobalDispatcher } from "undici";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, resolve } from "node:path";
|
|
import { sessionManager } from "./lib/SessionManager.js";
|
|
import { DownloadManager } from "./lib/DownloadManager.js";
|
|
|
|
// Otimização: Agente HTTP global com conexões persistentes e timeout relaxado.
|
|
// Isso ajuda a manter a performance em downloads paralelos e evita gargalos de socket.
|
|
const agent = new Agent({
|
|
connect: {
|
|
timeout: 30_000,
|
|
},
|
|
pipelining: 1,
|
|
keepAliveTimeout: 10_000,
|
|
connections: 500, // Aumenta limite de conexões simultâneas
|
|
});
|
|
|
|
setGlobalDispatcher(agent);
|
|
|
|
const app = Fastify({
|
|
logger: true,
|
|
disableRequestLogging: true, // Reduz ruído de log em transferências grandes
|
|
});
|
|
|
|
// Registro de CORS básico para permitir o frontend em desenvolvimento/produção.
|
|
await app.register(cors, {
|
|
origin: true,
|
|
});
|
|
|
|
// Servir frontend buildado (Vite) em produção.
|
|
const currentFile = fileURLToPath(import.meta.url);
|
|
const currentDir = dirname(currentFile);
|
|
const clientDistPath = resolve(currentDir, "../../web/dist");
|
|
|
|
await app.register(fastifyStatic, {
|
|
root: clientDistPath,
|
|
prefix: "/",
|
|
index: ["index.html"],
|
|
});
|
|
|
|
// Rota explícita para a SPA.
|
|
app.get("/", (_request, reply) => {
|
|
return reply.sendFile("index.html");
|
|
});
|
|
|
|
app.get("/health", async () => {
|
|
return { status: "ok" };
|
|
});
|
|
|
|
const downloadManager = new DownloadManager(agent);
|
|
|
|
// Helper to get session from header
|
|
const getSession = (req: FastifyRequest, reply: FastifyReply) => {
|
|
const authHeader = req.headers["x-session-id"];
|
|
const id = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
|
const session = sessionManager.getSession(id);
|
|
|
|
// Return the ID for the client to store if it's new
|
|
reply.header("x-session-id", session.id);
|
|
return session;
|
|
};
|
|
|
|
// 1. Session Status & Timer
|
|
app.get("/api/session", async (request, reply) => {
|
|
const session = getSession(request, reply);
|
|
return {
|
|
sessionId: session.id,
|
|
expiresAt: sessionManager.getExpiresAt(session.id),
|
|
remainingMs: sessionManager.getSessionTimeRemaining(session.id),
|
|
};
|
|
});
|
|
|
|
// 2. Queue Download
|
|
const queueSchema = z.object({
|
|
url: z.string().url(),
|
|
});
|
|
|
|
app.post("/api/queue", async (request, reply) => {
|
|
const session = getSession(request, reply);
|
|
|
|
const parse = queueSchema.safeParse(request.body);
|
|
if (!parse.success) return reply.status(400).send({ error: "Invalid URL" });
|
|
|
|
const task = await downloadManager.queueDownload(session.id, parse.data.url);
|
|
return task;
|
|
});
|
|
|
|
// 3. List Tasks (Polling)
|
|
app.get("/api/tasks", async (request, reply) => {
|
|
const session = getSession(request, reply);
|
|
const tasks = downloadManager.getTasks(session.id);
|
|
return tasks;
|
|
});
|
|
|
|
// 4. Download Completed File (Local -> User)
|
|
app.get("/api/download/:taskId", async (request, reply) => {
|
|
const session = getSession(request, reply);
|
|
const { taskId } = request.params as { taskId: string };
|
|
|
|
const task = downloadManager.getTask(taskId);
|
|
|
|
if (!task) return reply.status(404).send({ error: "Task not found" });
|
|
if (task.sessionId !== session.id) return reply.status(403).send({ error: "Unauthorized" });
|
|
if (task.status !== "completed") return reply.status(400).send({ error: "Download not ready" });
|
|
|
|
const sessionPath = sessionManager.getSessionPath(session.id);
|
|
const fileName = `${task.id}_${task.filename}`;
|
|
|
|
try {
|
|
sessionManager.touch(session.id); // Reset timer on interaction
|
|
// O @fastify/static resolve caminhos a partir do `root` do plugin (web/dist),
|
|
// então o root do diretório da sessão é passado por requisição.
|
|
// Sem isso a resposta era o index.html da SPA, não o arquivo baixado.
|
|
return reply.sendFile(fileName, sessionPath);
|
|
} catch (e) {
|
|
return reply.status(404).send({ error: "File removed or missing" });
|
|
}
|
|
});
|
|
|
|
// Legacy Stream (Redirect or Deprecate)
|
|
app.get("/stream", async (req, reply) => {
|
|
reply.status(410).send({ error: "Direct streaming is deprecated. Use the new Dashboard." });
|
|
});
|
|
|
|
// Fallback para SPA.
|
|
app.setNotFoundHandler((requestFastify, reply) => {
|
|
if (requestFastify.method === "GET") {
|
|
return reply.sendFile("index.html");
|
|
}
|
|
reply.status(404).send({ error: "Endpoint não encontrado." });
|
|
});
|
|
|
|
const port = Number(process.env.PORT) || 3000;
|
|
|
|
try {
|
|
await app.listen({
|
|
port,
|
|
host: "0.0.0.0",
|
|
});
|
|
console.log(`🚀 Server running on port ${port}`);
|
|
} catch (err) {
|
|
console.error(err);
|
|
process.exit(1);
|
|
}
|
|
|