TypeScript 97.5%
SQL 1.4%
Python 0.8%
1import { getWorkspace } from "@/lib/session";2import { parseRequestFilters, REQUESTS_EXPORT_MAX, type SearchParams } from "@/lib/requests-filters";3import { listRequestsBatch, type RequestListRow, type Scope } from "@/lib/queries/dashboard";45export const dynamic = "force-dynamic";67const COLUMNS: Array<[string, (r: RequestListRow) => unknown]> = [8 ["request_id", (r) => r.id],9 ["timestamp", (r) => r.createdAt.toISOString()],10 ["completed_at", (r) => r.completedAt?.toISOString() ?? ""],11 ["status", (r) => r.status],12 ["http_status", (r) => r.httpStatus ?? ""],13 ["error_code", (r) => r.errorCode ?? ""],14 ["method", (r) => r.method],15 ["url", (r) => r.url],16 ["final_url", (r) => r.finalUrl ?? ""],17 ["domain", (r) => r.domain],18 ["source", (r) => r.source],19 ["requested_network", (r) => r.requestedNetwork],20 ["network", (r) => r.network ?? ""],21 ["country", (r) => r.country ?? ""],22 ["region", (r) => r.region ?? ""],23 ["city", (r) => r.city ?? ""],24 ["session_id", (r) => r.sessionId ?? ""],25 ["latency_ms", (r) => r.latencyMs ?? ""],26 ["attempts", (r) => r.attempts],27 ["bytes_in", (r) => r.bytesIn],28 ["bytes_out", (r) => r.bytesOut],29 ["price_usd", (r) => r.priceUsd.toFixed(6)],30 ["cached", (r) => (r.cached ? "true" : "false")],31];3233/** RFC 4180 quoting plus a guard against spreadsheet formula injection. */34function cell(v: unknown): string {35 let s = v === null || v === undefined ? "" : String(v);36 if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;37 return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;38}3940const BATCH = 1000;4142/**43 * GET /dashboard/requests/export?…filters — streams a CSV of the current filter set (newest first),44 * capped at REQUESTS_EXPORT_MAX rows. Upstream cost is never included; `price_usd` is the billed price.45 */46export async function GET(req: Request) {47 const ws = await getWorkspace();48 const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id };49 const url = new URL(req.url);50 const sp: SearchParams = Object.fromEntries(url.searchParams.entries());51 const filters = parseRequestFilters(sp);52 const encoder = new TextEncoder();53 const state = { offset: 0, done: false };5455 const stream = new ReadableStream<Uint8Array>({56 async start(controller) {57 controller.enqueue(encoder.encode(`${COLUMNS.map(([name]) => name).join(",")}\r\n`));58 },59 async pull(controller) {60 // Pull-based: each call fetches one batch; state lives in the closure.61 const offset = state.offset;62 if (offset >= REQUESTS_EXPORT_MAX || state.done) {63 controller.close();64 return;65 }66 const limit = Math.min(BATCH, REQUESTS_EXPORT_MAX - offset);67 let rows: RequestListRow[];68 try {69 rows = await listRequestsBatch(scope, filters, offset, limit);70 } catch (e) {71 controller.error(e);72 return;73 }74 if (!rows.length) {75 state.done = true;76 controller.close();77 return;78 }79 const chunk = rows.map((r) => COLUMNS.map(([, get]) => cell(get(r))).join(",")).join("\r\n") + "\r\n";80 controller.enqueue(encoder.encode(chunk));81 state.offset += rows.length;82 if (rows.length < limit) state.done = true;83 },84 });8586 const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "").replace(/(\d{8})(\d{6})/, "$1-$2");87 const slug = ws.project.slug || "project";88 return new Response(stream, {89 headers: {90 "content-type": "text/csv; charset=utf-8",91 "content-disposition": `attachment; filename="fetcha-requests-${slug}-${stamp}.csv"`,92 "cache-control": "no-store",93 "x-content-type-options": "nosniff",94 },95 });96}97