TypeScript 97.5%
SQL 1.4%
Python 0.8%
1"use server";23import { revalidatePath } from "next/cache";4import { crawlCreateSchema, mapCreateSchema } from "@fetcha/core";5import { getWorkspace } from "@/lib/session";6import { internalApi, InternalApiError, type CrawlJob, type MapResult } from "@/lib/api";78export interface CrawlActionError {9 code: string;10 message: string;11 requestId: string | null;12 details?: Record<string, unknown>;13}1415export type CrawlActionResult<T> = { ok: true; data: T } | { ok: false; error: CrawlActionError };1617function invalid(issues: Array<{ path: string; message: string }>): CrawlActionError {18 const first = issues[0];19 return {20 code: "INVALID_REQUEST",21 message: first ? `${first.path ? `${first.path}: ` : ""}${first.message}` : "The request is invalid.",22 requestId: null,23 details: { issues },24 };25}2627function fromError(e: unknown, fallback: string): CrawlActionError {28 if (e instanceof InternalApiError) return { code: e.code, message: e.message, requestId: e.requestId, details: e.details };29 console.error("[crawls] unexpected error", e);30 return { code: "INTERNAL_ERROR", message: fallback, requestId: null };31}3233/** Strip empty strings / undefined so optional fields do not trip the strict schema. */34function clean(input: unknown): Record<string, unknown> {35 if (!input || typeof input !== "object") return {};36 return Object.fromEntries(Object.entries(input as Record<string, unknown>).filter(([, v]) => v !== undefined && v !== "" && v !== null));37}3839/**40 * Start a crawl job for the current project. Validated with the shared `crawlCreateSchema`; the API41 * enforces plan limits (pages per job, concurrent jobs). Never throws to the client.42 */43export async function createCrawl(input: unknown): Promise<CrawlActionResult<CrawlJob>> {44 const ws = await getWorkspace();45 try {46 const parsed = crawlCreateSchema.safeParse(clean(input));47 if (!parsed.success) return { ok: false, error: invalid(parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))) };48 const job = await internalApi.createCrawl(ws.project.id, ws.user.id, parsed.data);49 if (!job || typeof job !== "object" || typeof job.id !== "string") {50 return { ok: false, error: { code: "INTERNAL_ERROR", message: "The API returned an unexpected response.", requestId: null } };51 }52 revalidatePath("/dashboard/crawls");53 return { ok: true, data: job };54 } catch (e) {55 return { ok: false, error: fromError(e, "Could not start the crawl. Please try again.") };56 }57}5859/** Cancel a queued or running crawl job. */60export async function cancelCrawl(id: string): Promise<CrawlActionResult<{ id: string; status: "cancelled" }>> {61 const ws = await getWorkspace();62 if (!/^crawl_[A-Za-z0-9]{4,64}$/.test(id)) return { ok: false, error: { code: "INVALID_REQUEST", message: "Invalid crawl id.", requestId: null } };63 try {64 const res = await internalApi.cancelCrawl(ws.project.id, ws.user.id, id);65 revalidatePath("/dashboard/crawls");66 revalidatePath(`/dashboard/crawls/${id}`);67 return { ok: true, data: res };68 } catch (e) {69 return { ok: false, error: fromError(e, "Could not cancel the crawl. Please try again.") };70 }71}7273/** Discover the URLs of a site (sitemap + links), synchronously. */74export async function runMap(input: unknown): Promise<CrawlActionResult<MapResult>> {75 const ws = await getWorkspace();76 try {77 const parsed = mapCreateSchema.safeParse(clean(input));78 if (!parsed.success) return { ok: false, error: invalid(parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))) };79 const result = await internalApi.mapSite(ws.project.id, ws.user.id, parsed.data);80 if (!result || typeof result !== "object" || !Array.isArray(result.urls)) {81 return { ok: false, error: { code: "INTERNAL_ERROR", message: "The API returned an unexpected response.", requestId: null } };82 }83 return { ok: true, data: result };84 } catch (e) {85 return { ok: false, error: fromError(e, "Could not map the site. Please try again.") };86 }87}88