Enhance streaming functionality with optional byte offset support and serve frontend build in production. Added static file serving for SPA and improved error handling for streaming requests.
This commit is contained in:
1 parent
b9ca713a2a
commit
456f0fe056
1 file changed
+49
-18
Vendored
+49
-18
@@ -1,8 +1,10 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { z } from "zod";
|
||||
import { request } from "undici";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
});
|
||||
@@ -10,6 +12,19 @@ const app = Fastify({
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
});
|
||||
// Servir frontend buildado (Vite) em produção, similar ao Next.js.
|
||||
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 na raiz, útil especialmente em ambientes de deploy.
|
||||
app.get("/", (_request, reply) => {
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
app.get("/health", async () => {
|
||||
return { status: "ok" };
|
||||
});
|
||||
@@ -21,6 +36,12 @@ const streamQuerySchema = z.object({
|
||||
// Evita SSRF localizando apenas http/https genérico.
|
||||
return value.startsWith("http://") || value.startsWith("https://");
|
||||
}, { message: "Apenas URLs HTTP/HTTPS são permitidas." }),
|
||||
// Offset opcional em bytes para retomar download dentro da mesma sessão.
|
||||
offset: z
|
||||
.string()
|
||||
.regex(/^\d+$/)
|
||||
.transform((value) => Number.parseInt(value, 10))
|
||||
.optional(),
|
||||
});
|
||||
app.get("/stream", async (requestFastify, reply) => {
|
||||
const parseResult = streamQuerySchema.safeParse(requestFastify.query);
|
||||
@@ -29,7 +50,7 @@ app.get("/stream", async (requestFastify, reply) => {
|
||||
reply.status(400);
|
||||
return { error: firstError };
|
||||
}
|
||||
const { url } = parseResult.data;
|
||||
const { url, offset } = parseResult.data;
|
||||
try {
|
||||
// HEAD para validar link e obter metadados.
|
||||
const headResponse = await request(url, {
|
||||
@@ -64,9 +85,14 @@ app.get("/stream", async (requestFastify, reply) => {
|
||||
}
|
||||
return fallbackName;
|
||||
})();
|
||||
// GET streaming da origem.
|
||||
const headers = {};
|
||||
if (typeof offset === "number" && Number.isFinite(offset) && offset > 0) {
|
||||
headers.Range = `bytes=${offset}-`;
|
||||
}
|
||||
// GET streaming da origem (possivelmente com Range para retomada).
|
||||
const originResponse = await request(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
headersTimeout: 30000,
|
||||
maxRedirections: 3,
|
||||
});
|
||||
@@ -78,36 +104,41 @@ app.get("/stream", async (requestFastify, reply) => {
|
||||
reply.status(originResponse.statusCode);
|
||||
return { error: "Falha ao obter o arquivo de origem." };
|
||||
}
|
||||
const normalizedLength = Array.isArray(contentLength)
|
||||
? contentLength[0]
|
||||
: contentLength;
|
||||
// Configura headers de resposta para o cliente final.
|
||||
reply.headers({
|
||||
"Content-Type": Array.isArray(contentType) ? contentType[0] : contentType,
|
||||
...(contentLength
|
||||
...(normalizedLength
|
||||
? {
|
||||
"Content-Length": Array.isArray(contentLength)
|
||||
? contentLength[0]
|
||||
: contentLength,
|
||||
"Content-Length": normalizedLength,
|
||||
"X-Origin-Content-Length": normalizedLength,
|
||||
}
|
||||
: {}),
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
});
|
||||
// Se o cliente fechar a conexão, garantimos o encerramento do stream de origem.
|
||||
reply.raw.on("close", () => {
|
||||
originResponse.body?.destroy();
|
||||
});
|
||||
// Conecta o stream de origem diretamente ao socket do cliente.
|
||||
await pipeline(originResponse.body, reply.raw);
|
||||
// Fastify exige que retornemos void quando manipulamos reply.raw diretamente.
|
||||
return reply;
|
||||
// Entregamos o stream diretamente para o Fastify gerenciar (pipe + backpressure).
|
||||
return reply.send(originResponse.body);
|
||||
}
|
||||
catch (error) {
|
||||
requestFastify.log.error({ err: error, url }, "Erro durante o streaming do arquivo");
|
||||
if (!reply.sent) {
|
||||
reply.status(502);
|
||||
return { error: "Erro ao realizar o proxy do arquivo." };
|
||||
// Em caso de falha antes de iniciar o streaming, respondemos com JSON.
|
||||
return reply
|
||||
.status(502)
|
||||
.send({ error: "Erro ao realizar o proxy do arquivo." });
|
||||
}
|
||||
return reply;
|
||||
// Se a resposta já foi enviada/fechada, apenas não fazemos mais nada.
|
||||
}
|
||||
});
|
||||
// Fallback para SPA: qualquer rota GET não atendida cai no index.html.
|
||||
app.setNotFoundHandler((requestFastify, reply) => {
|
||||
if (requestFastify.method === "GET") {
|
||||
return reply.sendFile("index.html");
|
||||
}
|
||||
reply.status(404).send({ error: "Recurso não encontrado." });
|
||||
});
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
try {
|
||||
await app.listen({
|
||||
|
||||
Reference in new issue
Block a user