Files
Downloader/server/src/index.ts

157 lines
4.7 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";
import { DownloadManager } from "./lib/DownloadManager";
// 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 streamQuerySchema = z.object({
url: z.string().url("URL inválida").startsWith("http", "Apenas HTTP/HTTPS permitidos"),
// Offset opcional em bytes para retomar download.
offset: z
.string()
.regex(/^\d+$/)
.transform((value) => Number.parseInt(value, 10))
.optional(),
});
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 filePath = resolve(sessionPath, `${task.id}_${task.filename}`);
try {
sessionManager.touch(session.id); // Reset timer on interaction
return reply.download(filePath, task.filename); // requires @fastify/static or sendFile
} 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);
}