Author SHA1 Message Date
Evandro Fonseca Junior cc5147946c docs: reescreve o README para o estado real do projeto
O README descrevia um proxy de streaming com `GET /stream?url=&offset=` que não
existe mais (a rota responde 410 desde a reescrita do backend), falava em
"retomar downloads" e não mencionava sessão, storage temporário, polling de
tasks nem o endpoint de entrega do arquivo.

O novo texto segue o padrão da auditoria: one-liner, badges reais (sem licença
e sem CI, que não existem no repo), Sobre, Como funciona com diagrama, Stack,
Requisitos, Início rápido testado, Uso, API, Produção, Estrutura do projeto,
Verificação, Estado atual e limitações, Documentação e Licença.

O que está escrito corresponde ao que foi executado nesta auditoria: install,
build, lint, 13 checagens de API, download de 10 MB com sha256 idêntico ao da
origem e a interface rodando em Chrome headless.
2026-09-14 06:08:51 -03:00
Evandro Fonseca Junior ddccc80a70 fix(web): faz o modo dev funcionar e unifica a URL da API
Com `pnpm dev` e nenhuma configuração, o frontend chamava `/api` na própria
origem do Vite (5173), que devolvia o index.html da SPA em vez de JSON, e a
sessão nunca inicializava. Além disso os dois arquivos que falam com a API
usavam padrões diferentes: `SessionContext` usava URL relativa (correto em
produção, onde o Fastify serve a API e o build) e `DownloadItem` fixava
`http://localhost:3000`, o que quebra o botão "Save to Disk" quando o app roda
em outra máquina/VPS.

- `vite.config.ts`: proxy de `/api` para o backend em dev (alvo configurável por
  `VITE_API_PROXY`, padrão `http://localhost:3000`).
- `DownloadItem.tsx`: mesma origem por padrão, igual ao SessionContext
  (`VITE_API_URL` continua valendo para apontar para outro host).

Também deixa `pnpm --filter web lint` verde (4 erros do eslint): tipos de props
de Input/Textarea como type alias, `catch` sem binding e uma exceção
documentada para a regra de fast refresh no módulo do contexto.
2026-09-14 06:04:25 -03:00
Evandro Fonseca Junior b19c989c0e fix(server): conserta start de produção, entrega do arquivo e diretório de storage
Quatro defeitos verificados executando o servidor de produção:

1. `pnpm start` quebrava na primeira linha com ERR_MODULE_NOT_FOUND
   (`server/dist/lib/SessionManager`): os imports relativos não tinham extensão
   e o Node ESM não resolve `./lib/SessionManager`. Os imports agora terminam em
   `.js` (funciona no tsx em dev e no build do tsc).

2. `GET /api/download/:taskId` devolvia o index.html da SPA (450 bytes) em vez
   do arquivo baixado, porque o @fastify/static resolve o caminho a partir do
   `root` do plugin (web/dist). Agora o root do diretório da sessão é passado
   por requisição via `reply.sendFile(fileName, sessionPath)`.

3. O download podia sair truncado: o status virava "completed" logo depois de
   `fileStream.end()`, antes do flush em disco. A gravação passou a usar
   `stream/promises.pipeline` (respeita backpressure, espera o flush e propaga
   erro de escrita), com um Transform no meio mantendo o cálculo de
   progresso/velocidade.

4. A raiz de armazenamento era `resolve(process.cwd(), "server/storage")`; como
   `pnpm start` executa dentro de `server/`, os arquivos iam para
   `server/server/storage`. Agora a raiz é ancorada no diretório do módulo
   (`server/storage`), com override por `STORAGE_ROOT`, e o diretório entrou no
   .gitignore para os downloads não serem versionados.

Também usa `basename()` no nome de arquivo vindo do Content-Disposition e da
URL de origem, evitando escrever fora do diretório da sessão.
2026-09-14 06:04:15 -03:00
Evandro Fonseca Junior aedfe69da0 chore: deixa de versionar o build antigo em server/dist
O arquivo server/dist/index.js estava versionado (apesar de `dist/` estar no
.gitignore) e continha o build de novembro/2025, anterior à reescrita do
backend: ainda tinha o proxy antigo de /stream. O build é regenerado por
`pnpm build` (o nixpacks roda `pnpm build` antes do start), então o artefato
não precisa ficar no repositório.
2026-09-14 06:03:50 -03:00
Evandro Fonseca Junior 1d75dd74fb build(pnpm): autoriza o build do esbuild e documenta as chaves de workspace
O pnpm 11/12 ignora `onlyBuiltDependencies` e falha o install com
ERR_PNPM_IGNORED_BUILDS (esbuild, usado pelo Vite, precisa rodar script de
build). Adiciona `allowBuilds: {esbuild: true}` mantendo
`onlyBuiltDependencies` para pnpm 9/10 e comenta que `overrides` só é lido
pelo pnpm 10+.
2026-09-14 06:03:15 -03:00
12 changed files with 181 additions and 283 deletions

No files matched your search

+3
View File
@@ -5,6 +5,9 @@ build/
coverage/ coverage/
.npm/ .npm/
# Arquivos baixados pelas sessoes do Ephemeral Downloader
server/storage/
# Environment # Environment
.env .env
.env.local .env.local
+106 -109
View File
@@ -1,153 +1,150 @@
## Ephemeral Downloader # Ephemeral Downloader
Um proxy de download efêmero para você usar a **sua VPS como ponte de alta velocidade** entre servidores lentos e a sua máquina local. **Proxy de download efêmero: a sua VPS baixa o arquivo por uma rota melhor e entrega para o seu navegador, com os arquivos apagados automaticamente depois.**
Em vez de baixar direto de um host congestionado, você aponta o link para o Ephemeral Downloader, que: ![Node](https://img.shields.io/badge/Node-%3E%3D20.19-5FA04E?logo=nodedotjs&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)
![Fastify](https://img.shields.io/badge/Fastify-5-000000?logo=fastify&logoColor=white)
![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)
![Vite](https://img.shields.io/badge/Vite-7-646CFF?logo=vite&logoColor=white)
![pnpm](https://img.shields.io/badge/pnpm-9%2B-F69220?logo=pnpm&logoColor=white)
- Faz o download via VPS (geralmente com rota/peering melhor). ## Sobre
- Transmite o arquivo por **streaming** para o seu navegador.
- Libera os recursos assim que o download termina.
Simples, focado e pensado para quem vive baixando ISOs, ROMs e arquivos grandes. Baixar um arquivo grande direto de um host congestionado costuma ser lento, e muitas vezes o gargalo não é a sua internet nem o servidor de origem, e sim o caminho entre eles. O Ephemeral Downloader coloca a sua VPS no meio: o servidor faz o download (normalmente com peering melhor), mostra o progresso em tempo real e entrega o arquivo para você pelo navegador.
--- O projeto é focado em uso efêmero. O arquivo fica em um diretório temporário da sessão no servidor e é apagado quando a sessão expira (1 hora sem interação). Não é um proxy de streaming puro: o download passa pelo disco do servidor antes de chegar até você.
## Como funciona (visão rápida) ## Como funciona
- **Frontend (React + Vite)** ```
navegador (React + Vite)
│ POST /api/queue {url} GET /api/tasks (polling de 2 s)
▼ ▲
Fastify (server/) ──undici GET──▶ host de origem │
│ │
▼ │
server/storage/<id-da-sessao>/<task>_<arquivo> ────────┘
▲
│ GET /api/download/:taskId (arquivo concluído)
navegador
```
- Interface SPA com campo de texto para múltiplos links (um por linha). - `POST /api/queue` enfileira a URL e o backend baixa com `undici`, gravando em `server/storage/<id-da-sessão>/` por um pipeline (respeita backpressure e só marca a task como concluída depois do arquivo em disco).
- Lista de downloads recentes com: - O frontend faz polling de `GET /api/tasks` a cada 2 segundos e mostra status, progresso, bytes recebidos, total e velocidade em MB/s e Mbps.
- Progresso em tempo real. - Quando a task conclui, o botão "Save to Disk" busca `GET /api/download/:taskId` e salva o arquivo.
- Tamanho total e já baixado. - Em produção o próprio Fastify serve o build do Vite na raiz, com fallback da SPA.
- Velocidade em **MB/s** e **Mbps**. - `GET /stream` é o proxy antigo, mantido apenas respondendo 410 (descontinuado).
- Botão para **retomar downloads** que falharam dentro da mesma sessão.
- **Backend (Fastify + Streams)** ## Stack
- Rota `GET /stream?url=...&offset=...` que:
- Faz `HEAD` na origem para validar o link e obter `Content-Length`.
- Faz `GET` (ou `Range: bytes=offset-` para retomada).
- Faz o **streaming direto** para o cliente, sem guardar arquivo em disco.
- Em produção, também serve o build do frontend (Vite) na própria porta do servidor.
--- | Camada | Escolha |
|---|---|
| Runtime | Node.js 20.19 ou superior |
| Backend | Fastify 5, undici 7 (cliente HTTP), zod (validação) |
| Frontend | React 19, Vite 7, Tailwind CSS 4, lucide-react |
| Linguagem | TypeScript 5.9 |
| Monorepo | pnpm workspaces (`server` e `web`) |
| Deploy | nixpacks (`nixpacks.toml`) |
## Requisitos ## Requisitos
- Node.js **>= 20** - Node.js 20.19 ou superior (exigência do Vite 7 e do undici 7; verificado no Node 24.20.0)
- `pnpm` **>= 8** - pnpm 9 ou superior (verificado no pnpm 12.4.1; o lockfile é o formato 9.0)
- Portas livres: backend (padrão 3000) e Vite (5173) em desenvolvimento
--- Observação sobre pnpm: a seção `overrides` do `pnpm-workspace.yaml` (fastify e undici com versão mínima de segurança) só é aplicada pelo pnpm 10 ou superior. Com pnpm 9 ela é ignorada.
## Instalação ## Início rápido
Clone o repositório e instale as dependências:
```bash ```bash
git clone <seu-fork-ou-repo> Downloader
cd Downloader
pnpm install pnpm install
```
--- # desenvolvimento: Fastify (3000) + Vite (5173) juntos
## Scripts principais
Na raiz do projeto:
- **Desenvolvimento (backend + frontend juntos)**:
```bash
pnpm dev pnpm dev
# abra http://localhost:5173
``` ```
Isso sobe: Em desenvolvimento o Vite encaminha `/api` para o backend (proxy configurado em `web/vite.config.ts`), então não é preciso arquivo `.env`. Se a porta 3000 estiver ocupada:
- Fastify em `http://localhost:3000`
- Vite em `http://localhost:5173`
- **Build de produção (server + web)**:
```bash ```bash
pnpm build PORT=3100 VITE_API_PROXY=http://localhost:3100 pnpm dev
``` ```
- **Start de produção (tudo pela porta 3000)**: ## Uso
1. Cole a URL direta (http ou https) de um arquivo e clique em **Download**.
2. A lista de downloads mostra status, progresso, tamanho e velocidade, atualizada a cada 2 segundos.
3. Quando o download conclui, use **Save to Disk** para salvar o arquivo na sua máquina.
4. A sessão dura 1 hora de inatividade (o contador reinicia a cada interação) e depois os arquivos são apagados do servidor.
## API
| Método | Rota | O que faz |
|---|---|---|
| GET | `/health` | Devolve `{"status":"ok"}`. |
| GET | `/` | Serve a SPA (build do Vite). |
| GET | `/api/session` | Cria ou recupera a sessão e devolve `sessionId`, `expiresAt` e `remainingMs`. O id também vem no header `x-session-id`. |
| POST | `/api/queue` | Corpo `{"url":"https://..."}`. Enfileira e inicia o download, devolvendo a task criada. |
| GET | `/api/tasks` | Lista as tasks da sessão (usado no polling da interface). |
| GET | `/api/download/:taskId` | Entrega o arquivo concluído da task. |
| GET | `/stream` | Descontinuado: responde 410. |
A sessão é identificada pelo header `x-session-id` (a interface guarda o id em `localStorage`). Erros observados: 400 para URL inválida (`{"error":"Invalid URL"}`) ou download não pronto, 403 para task de outra sessão e 404 para task inexistente. Se a origem falhar (por exemplo, 404 no host de origem), a task fica com `status: "error"` e a mensagem em `error`.
## Produção
```bash ```bash
pnpm start pnpm build # server: tsc | web: tsc -b && vite build
PORT=3000 pnpm start # node server/dist/index.js: API + build do front na mesma porta
``` ```
O servidor Fastify: O `nixpacks.toml` descreve esse fluxo: build em `pnpm install --frozen-lockfile && pnpm build` e start em `pnpm start`.
- Servirá o build do Vite na raiz (`/`). Variáveis de ambiente: `PORT` (padrão 3000) e `STORAGE_ROOT` (padrão `server/storage`) no backend; `VITE_API_URL` (host da API no frontend) e `VITE_API_PROXY` (alvo do proxy em dev).
- Continuará expondo `GET /stream` para o proxy de download.
--- ## Estrutura do projeto
## Configuração de ambiente ```
server/ backend Fastify
### Desenvolvimento ├── src/index.ts rotas (/health, /api/*), CORS e static do front
└── src/lib/
Por padrão o frontend conversa com o backend em: ├── DownloadManager.ts download da origem com undici e gravação na sessão
└── SessionManager.ts sessões, expiração (1 h) e limpeza dos arquivos
```ts web/ frontend React + Vite
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3000"; ├── src/context/ SessionContext (sessão e polling das tasks)
├── src/components/ ui/ (Input, Button, Card, Alert...) e feature/ (form, item, timer)
└── vite.config.ts proxy de /api em desenvolvimento
.agent/ scaffolding de agentes de IA (não faz parte do runtime)
docs/PLAN.md vazio (placeholder nunca preenchido)
nixpacks.toml comandos de build e start do deploy
pnpm-workspace.yaml workspaces, allowBuilds e overrides
``` ```
Se você mudar a porta ou rodar o backend em outro host, basta criar um arquivo `.env` dentro de `web/`: ## Verificação
```bash Não há testes automatizados neste repositório. O que existe de fato:
VITE_API_URL=http://seu-backend:3000
```
### Produção (Nixpacks ou similar) - `pnpm build` roda o `tsc` do server e `tsc -b && vite build` do web, e é o gate atual;
- `pnpm --filter web lint` roda o eslint (passa com 1 warning de `exhaustive-deps` em `SessionContext.tsx`);
- verificação manual executada em 14/09/2026 (Linux, Node 24.20.0, pnpm 12.4.1): `pnpm install` e `pnpm build` com exit 0; 13 checagens de API passando (`/health`, `/`, `/api/session`, `/api/queue` com URL inválida e válida, `/api/tasks`, `/api/download/:id`, 400/403/404 e `/stream` respondendo 410); download de 10 MB conferido por sha256 idêntico ao da origem; interface verificada em Chrome headless (sessão inicializada, task concluída e "Save to Disk" baixando o arquivo com o mesmo sha256).
- Defina a variável `PORT` (a maioria das plataformas faz isso automaticamente). Adicionar testes automatizados é o próximo passo natural do projeto.
- O servidor Fastify faz fallback para `3000` se `PORT` não estiver definida.
- O arquivo `nixpacks.toml` já descreve:
- Comando de build: `pnpm build`
- Comando de start: `pnpm start`
--- ## Estado atual e limitações
## Fluxo de uso típico - Sessões e tasks ficam em memória: reiniciar o servidor perde a lista de tasks (os arquivos em disco são removidos pelo coletor depois de 1 hora).
- Sem autenticação: qualquer um que alcance o servidor pode enfileirar downloads. Coloque atrás de VPN, autenticação reversa ou firewall.
- Não há retomada de download nem uso de `Range` no fluxo atual (o parâmetro `offset` do antigo `/stream` não existe mais).
- Não há limite de tamanho, banda ou cota: um link muito grande ocupa o disco do servidor até a sessão expirar.
- URLs que não sejam http/https são aceitas na fila e falham depois com `status: "error"`.
- Sem CI configurada e sem arquivo de licença.
- O diretório `.agent/` é scaffolding de ferramentas de IA e não é usado pela aplicação.
1. Você tem uma **VPS bem localizada** (por exemplo, no mesmo continente do servidor de origem). ## Documentação
2. No seu desktop/notebook, abre o Ephemeral Downloader.
3. Cola um ou mais links HTTP/HTTPS diretos (por exemplo, ROMs no SourceForge).
4. Clica em **“Iniciar download”**.
5. A VPS baixa em alta velocidade e o app:
- Mostra o progresso.
- Indica tamanho total / baixado.
- Mostra velocidade agregada em MB/s e Mbps.
6. Se a conexão cair, você pode **retomar** dentro da mesma sessão.
--- Não há documentação interna além deste README: `docs/PLAN.md` está vazio e `web/README.md` é o texto padrão do template do Vite.
## Limitações intencionais ## Licença
- **Retomada entre sessões** (fechar aba / desligar máquina) **não é suportada**. Sem licença definida no repositório: não existe arquivo `LICENSE` e o `package.json` está marcado como `private`. Defina uma licença antes de reutilizar ou publicar o código.
- O projeto é focado em uso efêmero, sem manter arquivos em disco no servidor.
- O servidor realiza apenas:
- Validação básica de URL (somente `http`/`https`).
- Proxy por streaming com **backpressure** nativo do Node.js.
- Nenhuma autenticação ou controle de cota por usuário (idealmente coloque atrás de VPN, auth reverso ou firewall).
---
## Ideias futuras
- Autenticação simples para restringir o painel.
- Limite de banda por IP/usuário.
- Modo “linha de comando” usando a mesma API do servidor.
---
## Por que esse projeto existe?
Porque às vezes o problema não é a sua internet nem o servidor de origem, e sim o **caminho entre eles**.
Colocando uma VPS no meio do caminho, você “puxa” o arquivo por uma rota melhor até a VPS e, de lá, faz o streaming até você com muito mais previsibilidade.
O Ephemeral Downloader encapsula essa ideia em uma interface simples, focada em quem só quer **colar o link, apertar um botão e acompanhar o progresso**.
+8
View File
@@ -2,9 +2,17 @@ packages:
- server - server
- web - web
# pnpm 11/12: sem isto o install falha com ERR_PNPM_IGNORED_BUILDS,
# porque o esbuild (usado pelo Vite) precisa rodar script de build.
allowBuilds:
esbuild: true
# Chave equivalente para pnpm 9/10 (ignorada pelo pnpm 12).
onlyBuiltDependencies: onlyBuiltDependencies:
- esbuild - esbuild
# Overrides de segurança. Atenção: pnpm 9 e anterior ignoram esta seção
# (o pnpm 10+ lê overrides de pnpm-workspace.yaml).
overrides: overrides:
fastify@<5.7.2: '>=5.7.2' fastify@<5.7.2: '>=5.7.2'
fastify@<=5.7.2: '>=5.7.3' fastify@<=5.7.2: '>=5.7.3'
-153
View File
@@ -1,153 +0,0 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import fastifyStatic from "@fastify/static";
import { z } from "zod";
import { request } from "undici";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const app = Fastify({
logger: true,
});
// 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, 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" };
});
const streamQuerySchema = z.object({
url: z
.string()
.url("URL inválida")
.refine((value) => {
// 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);
if (!parseResult.success) {
const firstError = parseResult.error.errors[0]?.message ?? "Parâmetros inválidos";
reply.status(400);
return { error: firstError };
}
const { url, offset } = parseResult.data;
try {
// HEAD para validar link e obter metadados.
const headResponse = await request(url, {
method: "HEAD",
// timeout conservador, em produção pode ser configurável.
headersTimeout: 15000,
});
if (headResponse.statusCode && headResponse.statusCode >= 400) {
reply.status(502);
return { error: "Não foi possível acessar o recurso de origem." };
}
const contentType = headResponse.headers["content-type"] ?? "application/octet-stream";
const contentLength = headResponse.headers["content-length"];
const originDisposition = headResponse.headers["content-disposition"];
// Nome de arquivo derivado do header Content-Disposition ou da URL.
const fallbackName = (() => {
try {
const parsedUrl = new URL(url);
const nameFromPath = parsedUrl.pathname.split("/").filter(Boolean).pop();
return nameFromPath ?? "download.bin";
}
catch {
return "download.bin";
}
})();
const filename = (() => {
if (typeof originDisposition === "string") {
const match = /filename\*?=(?:UTF-8''|")?([^";]+)/i.exec(originDisposition);
if (match?.[1]) {
return decodeURIComponent(match[1].replace(/"/g, ""));
}
}
return fallbackName;
})();
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,
});
if (!originResponse.body) {
reply.status(502);
return { error: "Resposta de origem sem corpo para streaming." };
}
if (originResponse.statusCode && originResponse.statusCode >= 400) {
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,
...(normalizedLength
? {
"Content-Length": normalizedLength,
"X-Origin-Content-Length": normalizedLength,
}
: {}),
"Content-Disposition": `attachment; filename="${filename}"`,
});
// 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) {
// 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." });
}
// 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({
port,
host: "0.0.0.0",
});
app.log.info(`Server listening on port ${port}`);
}
catch (err) {
app.log.error(err);
process.exit(1);
}
+7 -4
View File
@@ -5,8 +5,8 @@ import { z } from "zod";
import { Agent, setGlobalDispatcher } from "undici"; import { Agent, setGlobalDispatcher } from "undici";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { sessionManager } from "./lib/SessionManager"; import { sessionManager } from "./lib/SessionManager.js";
import { DownloadManager } from "./lib/DownloadManager"; import { DownloadManager } from "./lib/DownloadManager.js";
// Otimização: Agente HTTP global com conexões persistentes e timeout relaxado. // 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. // Isso ajuda a manter a performance em downloads paralelos e evita gargalos de socket.
@@ -118,11 +118,14 @@ app.get("/api/download/:taskId", async (request, reply) => {
if (task.status !== "completed") return reply.status(400).send({ error: "Download not ready" }); if (task.status !== "completed") return reply.status(400).send({ error: "Download not ready" });
const sessionPath = sessionManager.getSessionPath(session.id); const sessionPath = sessionManager.getSessionPath(session.id);
const filePath = resolve(sessionPath, `${task.id}_${task.filename}`); const fileName = `${task.id}_${task.filename}`;
try { try {
sessionManager.touch(session.id); // Reset timer on interaction sessionManager.touch(session.id); // Reset timer on interaction
return reply.download(filePath, task.filename); // requires @fastify/static or sendFile // 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) { } catch (e) {
return reply.status(404).send({ error: "File removed or missing" }); return reply.status(404).send({ error: "File removed or missing" });
} }
+25 -9
View File
@@ -1,8 +1,10 @@
import { createWriteStream, existsSync, readFileSync, writeFileSync } from "node:fs"; import { createWriteStream, existsSync } from "node:fs";
import { join } from "node:path"; import { basename, join } from "node:path";
import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";
import { request, Dispatcher } from "undici"; import { request, Dispatcher } from "undici";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { sessionManager } from "./SessionManager"; import { sessionManager } from "./SessionManager.js";
type DownloadStatus = "queued" | "downloading" | "completed" | "error"; type DownloadStatus = "queued" | "downloading" | "completed" | "error";
@@ -45,7 +47,7 @@ export class DownloadManager {
try { try {
const u = new URL(url); const u = new URL(url);
const p = u.pathname.split("/").pop(); const p = u.pathname.split("/").pop();
if (p) filename = p; if (p) filename = basename(p);
} catch { } } catch { }
const task: DownloadTask = { const task: DownloadTask = {
@@ -94,7 +96,12 @@ export class DownloadManager {
if (typeof disposition === "string") { if (typeof disposition === "string") {
const match = /filename\*?=(?:UTF-8''|")?([^";]+)/i.exec(disposition); const match = /filename\*?=(?:UTF-8''|")?([^";]+)/i.exec(disposition);
if (match?.[1]) { if (match?.[1]) {
task.filename = decodeURIComponent(match[1].replace(/"/g, "")); // O header vem do host de origem: usar apenas o nome base evita
// caminho relativo (../) escrevendo fora do diretório da sessão.
const headerName = basename(decodeURIComponent(match[1].replace(/"/g, "")));
if (headerName) {
task.filename = headerName;
}
} }
} }
@@ -110,8 +117,10 @@ export class DownloadManager {
let lastUpdate = Date.now(); let lastUpdate = Date.now();
let bytesAtLastUpdate = 0; let bytesAtLastUpdate = 0;
for await (const chunk of response.body) { // Contador de progresso no meio do pipeline: mantém o cálculo de
fileStream.write(chunk); // velocidade/progresso sem ler o stream manualmente.
const progressTracker = new Transform({
transform(chunk, _encoding, callback) {
task.receivedBytes += chunk.length; task.receivedBytes += chunk.length;
const now = Date.now(); const now = Date.now();
@@ -130,9 +139,16 @@ export class DownloadManager {
// Touch session to keep it alive during download // Touch session to keep it alive during download
sessionManager.touch(task.sessionId); sessionManager.touch(task.sessionId);
} }
}
fileStream.end(); callback(null, chunk);
},
});
// `pipeline` espera o flush em disco e propaga erro de escrita.
// Antes o status virava "completed" antes do arquivo terminar de
// ser gravado e o download podia sair truncado.
await pipeline(response.body, progressTracker, fileStream);
task.status = "completed"; task.status = "completed";
task.progress = 100; task.progress = 100;
task.speed = 0; task.speed = 0;
+11 -2
View File
@@ -1,8 +1,17 @@
import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs"; import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs";
import { join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
const STORAGE_ROOT = resolve(process.cwd(), "server/storage"); // A raiz de armazenamento é ancorada no diretório do próprio módulo (server/),
// e não no `process.cwd()`: com o cwd errado (por exemplo rodando via
// `pnpm --filter server start`, que executa dentro de server/) os arquivos
// acabavam em server/server/storage. STORAGE_ROOT permite sobrescrever.
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
const SERVER_ROOT = resolve(MODULE_DIR, "..", "..");
const STORAGE_ROOT = process.env.STORAGE_ROOT
? resolve(process.env.STORAGE_ROOT)
: resolve(SERVER_ROOT, "storage");
const SESSION_TIMEOUT_MS = 60 * 60 * 1000; // 1 Hour const SESSION_TIMEOUT_MS = 60 * 60 * 1000; // 1 Hour
type SessionData = { type SessionData = {
+4 -2
View File
@@ -10,7 +10,9 @@ import { Card, CardContent } from "../ui/Card";
import { Progress } from "../ui/ProgressBar"; import { Progress } from "../ui/ProgressBar";
import { formatBytes, formatSpeed } from "../../lib/utils"; import { formatBytes, formatSpeed } from "../../lib/utils";
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3000"; // Mesma origem por padrão (em produção o Fastify serve a API e o build do front).
// Defina VITE_API_URL para apontar para outro host.
const API_URL = import.meta.env.VITE_API_URL || "";
export type DownloadStatus = "queued" | "downloading" | "completed" | "error"; export type DownloadStatus = "queued" | "downloading" | "completed" | "error";
@@ -55,7 +57,7 @@ export function DownloadItem({ item }: DownloadItemProps) {
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
} catch (e) { } catch {
alert("Failed to download file"); alert("Failed to download file");
} }
}; };
+1 -2
View File
@@ -1,8 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface InputProps export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
extends React.InputHTMLAttributes<HTMLInputElement> { }
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
+1 -2
View File
@@ -1,8 +1,7 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface TextareaProps export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {
+3
View File
@@ -126,6 +126,9 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
); );
} }
// O hook é exportado junto do provider de propósito: o arquivo é o módulo do
// contexto da sessão. A regra abaixo só afeta o Fast Refresh em desenvolvimento.
// eslint-disable-next-line react-refresh/only-export-components
export function useSession() { export function useSession() {
const context = useContext(SessionContext); const context = useContext(SessionContext);
if (!context) throw new Error("useSession must be used within SessionProvider"); if (!context) throw new Error("useSession must be used within SessionProvider");
+12
View File
@@ -4,4 +4,16 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
// O frontend chama a API na mesma origem (URL relativa). Em produção quem
// serve o build é o próprio Fastify; em desenvolvimento este proxy encaminha
// /api para o backend. Ajuste o alvo com VITE_API_PROXY se o backend não
// estiver na porta padrão (3000).
proxy: {
'/api': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
changeOrigin: true,
},
},
},
}) })