226 lines
7.0 KiB
TypeScript

import { env } from "cloudflare:workers";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import {
AUDIT_LIMITS,
clampAuditMaxPages,
getEstimatedAuditCapacity,
type AuditLimitTier,
} from "@/server/features/audit/services/audit-capacity";
import { AppError } from "@/server/lib/errors";
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
import {
parseAuditConfig,
type AuditConfig,
type LighthouseStrategy,
} from "@/server/lib/audit/types";
import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
async function startAudit(input: {
actorUserId: string;
billingCustomer: BillingCustomerContext;
projectId: string;
startUrl: string;
maxPages?: number;
lighthouseStrategy?: LighthouseStrategy;
limitTier: AuditLimitTier;
}) {
const limits = AUDIT_LIMITS[input.limitTier];
const maxPages = clampAuditMaxPages(input.maxPages);
if (maxPages > limits.maxPagesPerAudit) {
throw new AppError("AUDIT_PAGE_LIMIT_EXCEEDED");
}
const lighthouseStrategy = input.lighthouseStrategy ?? "auto";
const reservation = getEstimatedAuditCapacity({
maxPages,
lighthouseStrategy,
});
const auditId = crypto.randomUUID();
const config: AuditConfig = { maxPages, lighthouseStrategy };
const startUrl = await normalizeAndValidateStartUrl(input.startUrl);
await AuditRepository.createAudit({
id: auditId,
projectId: input.projectId,
startedByUserId: input.actorUserId,
startUrl,
workflowInstanceId: auditId,
config,
pagesTotal: reservation.pagesTotal,
lighthouseTotal: reservation.lighthouseTotal,
});
try {
// Concurrency and capacity are enforced after the insert, not before: a
// pre-insert read is a check-then-act race, so parallel requests would all
// pass the free tier's one-running-audit gate. Post-insert, each request
// sees at least its own row, so at most one racer can pass; the losers
// roll back via the catch below. Two true racers may both abort — the
// user just retries.
const usage = await AuditRepository.getAuditUsageForUser(input.actorUserId);
if (usage.runningCount > limits.maxRunningAudits) {
throw new AppError("AUDIT_ALREADY_RUNNING");
}
if (usage.capacityUnits > limits.maxCapacityUnits) {
throw new AppError("AUDIT_CAPACITY_REACHED");
}
await env.SITE_AUDIT_WORKFLOW.create({
id: auditId,
params: {
auditId,
billingCustomer: {
userId: input.billingCustomer.userId,
userEmail: input.billingCustomer.userEmail,
organizationId: input.billingCustomer.organizationId,
projectId: input.billingCustomer.projectId,
},
projectId: input.projectId,
startUrl,
config,
},
});
} catch (error) {
try {
const instance = await env.SITE_AUDIT_WORKFLOW.get(auditId);
await instance.terminate();
} catch {
// The workflow may never have been created, or may already be gone.
}
await AuditRepository.deleteAuditForProject(auditId, input.projectId);
throw error;
}
return { auditId };
}
async function getStatus(auditId: string, projectId: string) {
const audit = await AuditRepository.getAuditForProject(auditId, projectId);
if (!audit) throw new AppError("NOT_FOUND");
return {
id: audit.id,
startUrl: audit.startUrl,
status: audit.status,
pagesCrawled: audit.pagesCrawled,
pagesTotal: audit.pagesTotal,
lighthouseTotal: audit.lighthouseTotal,
lighthouseCompleted: audit.lighthouseCompleted,
lighthouseFailed: audit.lighthouseFailed,
currentPhase: audit.currentPhase,
startedAt: audit.startedAt,
completedAt: audit.completedAt,
};
}
async function getResults(auditId: string, projectId: string) {
const { audit, pages, lighthouse } =
await AuditRepository.getAuditResultsForProject(auditId, projectId);
if (!audit) throw new AppError("NOT_FOUND");
const parsedConfig = parseAuditConfig(audit.config);
if (!parsedConfig) {
throw new AppError("INTERNAL_ERROR", "Invalid audit configuration");
}
return {
audit: {
id: audit.id,
startUrl: audit.startUrl,
status: audit.status,
pagesCrawled: audit.pagesCrawled,
pagesTotal: audit.pagesTotal,
startedAt: audit.startedAt,
completedAt: audit.completedAt,
config: parsedConfig,
},
pages,
lighthouse,
};
}
async function getHistory(projectId: string) {
const auditList = await AuditRepository.getAuditsByProject(projectId);
return auditList.map((audit) => {
const parsedConfig = parseAuditConfig(audit.config);
const ranLighthouse = parsedConfig?.lighthouseStrategy !== "none";
return {
id: audit.id,
startUrl: audit.startUrl,
status: audit.status,
pagesCrawled: audit.pagesCrawled,
pagesTotal: audit.pagesTotal,
ranLighthouse,
startedAt: audit.startedAt,
completedAt: audit.completedAt,
};
});
}
async function getCrawlProgress(auditId: string, projectId: string) {
const audit = await AuditRepository.getAuditForProject(auditId, projectId);
if (!audit) {
throw new AppError("NOT_FOUND");
}
return AuditProgressKV.getCrawledUrls(auditId);
}
async function remove(auditId: string, projectId: string) {
const audit = await AuditRepository.getAuditForProject(auditId, projectId);
if (!audit) {
throw new AppError("NOT_FOUND");
}
if (audit.status === "running") {
if (!audit.workflowInstanceId) {
throw new AppError(
"CONFLICT",
"Cannot delete a running audit without workflow context.",
);
}
// A row can be "running" with no live workflow instance if a start failed
// between the row insert and workflow creation and its rollback delete
// also failed. Nothing to terminate then — deleting the row is the fix.
const instance = await env.SITE_AUDIT_WORKFLOW.get(
audit.workflowInstanceId,
).catch(() => null);
try {
await instance?.terminate();
} catch (error) {
// terminate() throws when the instance already reached a terminal state
// (it completed or errored in the moment before the user hit stop). That
// race shouldn't block deletion — re-check the live status and only fail
// if the workflow is genuinely still running.
const status = await instance?.status().catch(() => null);
const stillRunning =
status != null &&
["queued", "running", "paused", "waiting", "waitingForPause"].includes(
status.status,
);
if (stillRunning) {
console.error(`Failed to terminate audit workflow ${audit.id}:`, error);
throw new AppError("CONFLICT", "Unable to stop the running audit.");
}
}
}
await AuditRepository.deleteAuditForProject(auditId, projectId);
}
export const AuditService = {
startAudit,
getStatus,
getCrawlProgress,
getResults,
getHistory,
remove,
} as const;