SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
3.6 KB · 97 lines typescript
Raw Blame History
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