226 lines
7.0 KiB
TypeScript
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;
|