mirror of
https://github.com/jakejarvis/hoot.git
synced 2025-10-18 20:14:25 -04:00
Dedupe DNS and headers with Redis locks and aggregate cache; add concurrency tests; micro-tune hosting query gating (#87)
This commit is contained in:
@@ -35,19 +35,37 @@ export function DomainReportView({ domain }: { domain: string }) {
|
||||
registration.data?.isRegistered ?? false,
|
||||
);
|
||||
|
||||
// Disable export until all sections are settled (loaded or errored)
|
||||
// Consider sections "settled" only when they have either succeeded or errored.
|
||||
// This avoids showing empty states before a query has actually completed
|
||||
// (including while a query is disabled/pending due to gating conditions).
|
||||
const dnsSettled = !!(dns.isSuccess || dns.isError || dns.data !== undefined);
|
||||
const hostingSettled = !!(
|
||||
hosting.isSuccess ||
|
||||
hosting.isError ||
|
||||
hosting.data !== undefined
|
||||
);
|
||||
const certsSettled = !!(
|
||||
certs.isSuccess ||
|
||||
certs.isError ||
|
||||
certs.data !== undefined
|
||||
);
|
||||
const headersSettled = !!(
|
||||
headers.isSuccess ||
|
||||
headers.isError ||
|
||||
headers.data !== undefined
|
||||
);
|
||||
const seoSettled = !!(seo.isSuccess || seo.isError || seo.data !== undefined);
|
||||
|
||||
// Disable export until all secondary sections are settled
|
||||
const areSecondarySectionsLoading =
|
||||
registration.data?.isRegistered &&
|
||||
(dns.isLoading ||
|
||||
hosting.isLoading ||
|
||||
certs.isLoading ||
|
||||
headers.isLoading ||
|
||||
seo.isLoading ||
|
||||
dns.isFetching ||
|
||||
hosting.isFetching ||
|
||||
certs.isFetching ||
|
||||
headers.isFetching ||
|
||||
seo.isFetching);
|
||||
!!registration.data?.isRegistered &&
|
||||
!(
|
||||
dnsSettled &&
|
||||
hostingSettled &&
|
||||
certsSettled &&
|
||||
headersSettled &&
|
||||
seoSettled
|
||||
);
|
||||
|
||||
const handleExportJson = () => {
|
||||
captureClient("export_json_clicked", { domain });
|
||||
@@ -132,7 +150,7 @@ export function DomainReportView({ domain }: { domain: string }) {
|
||||
|
||||
<HostingEmailSection
|
||||
data={hosting.data || null}
|
||||
isLoading={hosting.isLoading}
|
||||
isLoading={!hostingSettled}
|
||||
isError={!!hosting.isError}
|
||||
onRetryAction={() => {
|
||||
captureClient("section_refetch_clicked", {
|
||||
@@ -145,7 +163,7 @@ export function DomainReportView({ domain }: { domain: string }) {
|
||||
|
||||
<DnsRecordsSection
|
||||
records={dns.data?.records || null}
|
||||
isLoading={dns.isLoading}
|
||||
isLoading={!dnsSettled}
|
||||
isError={!!dns.isError}
|
||||
onRetryAction={() => {
|
||||
captureClient("section_refetch_clicked", {
|
||||
@@ -158,7 +176,7 @@ export function DomainReportView({ domain }: { domain: string }) {
|
||||
|
||||
<CertificatesSection
|
||||
data={certs.data || null}
|
||||
isLoading={certs.isLoading}
|
||||
isLoading={!certsSettled}
|
||||
isError={!!certs.isError}
|
||||
onRetryAction={() => {
|
||||
captureClient("section_refetch_clicked", {
|
||||
@@ -171,7 +189,7 @@ export function DomainReportView({ domain }: { domain: string }) {
|
||||
|
||||
<HeadersSection
|
||||
data={headers.data || null}
|
||||
isLoading={headers.isLoading}
|
||||
isLoading={!headersSettled}
|
||||
isError={!!headers.isError}
|
||||
onRetryAction={() => {
|
||||
captureClient("section_refetch_clicked", {
|
||||
@@ -184,7 +202,7 @@ export function DomainReportView({ domain }: { domain: string }) {
|
||||
|
||||
<SeoSection
|
||||
data={seo.data || null}
|
||||
isLoading={seo.isLoading}
|
||||
isLoading={!seoSettled}
|
||||
isError={!!seo.isError}
|
||||
onRetryAction={() => {
|
||||
captureClient("section_refetch_clicked", {
|
||||
|
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ShieldQuestionMark,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
|
||||
@@ -11,6 +16,13 @@ import { RelativeExpiry } from "@/components/domain/relative-expiry";
|
||||
import { Section } from "@/components/domain/section";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -278,7 +290,20 @@ export function CertificatesSection({
|
||||
message="Failed to load certificates."
|
||||
onRetryAction={onRetryAction}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<Empty className="border border-dashed">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<ShieldQuestionMark />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No certificates found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
We couldn't retrieve a TLS certificate chain for this site.
|
||||
Ensure the domain resolves and serves HTTPS on port 443.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
@@ -1,11 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Earth } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { DnsGroup } from "@/components/domain/dns-group";
|
||||
import { DnsRecordList } from "@/components/domain/dns-record-list";
|
||||
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
|
||||
import { KeyValueSkeleton } from "@/components/domain/key-value-skeleton";
|
||||
import { Section } from "@/components/domain/section";
|
||||
import { SubheadCountSkeleton } from "@/components/domain/subhead-count";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import type { DnsRecord } from "@/lib/schemas";
|
||||
import { SECTION_DEFS } from "@/lib/sections-meta";
|
||||
|
||||
@@ -47,6 +56,20 @@ export function DnsRecordsSection({
|
||||
isError: boolean;
|
||||
onRetryAction: () => void;
|
||||
}) {
|
||||
const recordsByType = useMemo(() => {
|
||||
const byType: Record<DnsRecord["type"], DnsRecord[]> = {
|
||||
A: [],
|
||||
AAAA: [],
|
||||
MX: [],
|
||||
TXT: [],
|
||||
NS: [],
|
||||
};
|
||||
records?.forEach((r) => {
|
||||
byType[r.type].push(r);
|
||||
});
|
||||
return byType;
|
||||
}, [records]);
|
||||
|
||||
return (
|
||||
<Section {...SECTION_DEFS.dns} isError={isError} isLoading={isLoading}>
|
||||
{isLoading ? (
|
||||
@@ -59,41 +82,59 @@ export function DnsRecordsSection({
|
||||
</div>
|
||||
) : records ? (
|
||||
<div className="space-y-4">
|
||||
<DnsGroup
|
||||
title="A Records"
|
||||
color="blue"
|
||||
count={records.filter((r) => r.type === "A").length}
|
||||
>
|
||||
<DnsRecordList records={records} type="A" />
|
||||
</DnsGroup>
|
||||
<DnsGroup
|
||||
title="AAAA Records"
|
||||
color="cyan"
|
||||
count={records.filter((r) => r.type === "AAAA").length}
|
||||
>
|
||||
<DnsRecordList records={records} type="AAAA" />
|
||||
</DnsGroup>
|
||||
<DnsGroup
|
||||
title="MX Records"
|
||||
color="green"
|
||||
count={records.filter((r) => r.type === "MX").length}
|
||||
>
|
||||
<DnsRecordList records={records} type="MX" />
|
||||
</DnsGroup>
|
||||
<DnsGroup
|
||||
title="TXT Records"
|
||||
color="orange"
|
||||
count={records.filter((r) => r.type === "TXT").length}
|
||||
>
|
||||
<DnsRecordList records={records} type="TXT" />
|
||||
</DnsGroup>
|
||||
<DnsGroup
|
||||
title="NS Records"
|
||||
color="purple"
|
||||
count={records.filter((r) => r.type === "NS").length}
|
||||
>
|
||||
<DnsRecordList records={records} type="NS" />
|
||||
</DnsGroup>
|
||||
{records.length > 0 ? (
|
||||
<>
|
||||
<DnsGroup
|
||||
title="A Records"
|
||||
color="blue"
|
||||
count={recordsByType.A.length}
|
||||
>
|
||||
<DnsRecordList records={recordsByType.A} type="A" />
|
||||
</DnsGroup>
|
||||
<DnsGroup
|
||||
title="AAAA Records"
|
||||
color="cyan"
|
||||
count={recordsByType.AAAA.length}
|
||||
>
|
||||
<DnsRecordList records={recordsByType.AAAA} type="AAAA" />
|
||||
</DnsGroup>
|
||||
<DnsGroup
|
||||
title="MX Records"
|
||||
color="green"
|
||||
count={recordsByType.MX.length}
|
||||
>
|
||||
<DnsRecordList records={recordsByType.MX} type="MX" />
|
||||
</DnsGroup>
|
||||
<DnsGroup
|
||||
title="TXT Records"
|
||||
color="orange"
|
||||
count={recordsByType.TXT.length}
|
||||
>
|
||||
<DnsRecordList records={recordsByType.TXT} type="TXT" />
|
||||
</DnsGroup>
|
||||
<DnsGroup
|
||||
title="NS Records"
|
||||
color="purple"
|
||||
count={recordsByType.NS.length}
|
||||
>
|
||||
<DnsRecordList records={recordsByType.NS} type="NS" />
|
||||
</DnsGroup>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="border border-dashed">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Earth />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No DNS records found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
We couldn't resolve A/AAAA, MX, TXT, or NS records for
|
||||
this domain. If DNS was recently updated, it may take time to
|
||||
propagate.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<ErrorWithRetry
|
||||
|
@@ -1,9 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Logs } from "lucide-react";
|
||||
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
|
||||
import { KeyValue } from "@/components/domain/key-value";
|
||||
import { KeyValueSkeleton } from "@/components/domain/key-value-skeleton";
|
||||
import { Section } from "@/components/domain/section";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import type { HttpHeader } from "@/lib/schemas";
|
||||
import { SECTION_DEFS } from "@/lib/sections-meta";
|
||||
|
||||
@@ -26,7 +34,7 @@ export function HeadersSection({
|
||||
<KeyValueSkeleton key={id} widthClass="w-[100px]" withTrailing />
|
||||
))}
|
||||
</div>
|
||||
) : data ? (
|
||||
) : data && data.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{(() => {
|
||||
const important = new Set([
|
||||
@@ -56,7 +64,20 @@ export function HeadersSection({
|
||||
message="Failed to load headers."
|
||||
onRetryAction={onRetryAction}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<Empty className="border border-dashed">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Logs />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No HTTP headers detected</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
We couldn't fetch any HTTP response headers for this site. It
|
||||
may be offline or blocking requests.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
@@ -1,11 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { MailQuestionMark } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
|
||||
import { Favicon } from "@/components/domain/favicon";
|
||||
import { KeyValue } from "@/components/domain/key-value";
|
||||
import { KeyValueSkeleton } from "@/components/domain/key-value-skeleton";
|
||||
import { Section } from "@/components/domain/section";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import type { Hosting } from "@/lib/schemas";
|
||||
import { SECTION_DEFS } from "@/lib/sections-meta";
|
||||
|
||||
@@ -124,7 +132,21 @@ export function HostingEmailSection({
|
||||
message="Failed to load hosting details."
|
||||
onRetryAction={onRetryAction}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<Empty className="border border-dashed">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<MailQuestionMark />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No hosting details available</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
We couldn't detect hosting, email, or DNS provider info. If
|
||||
the domain has no A/AAAA records or blocked headers, details may
|
||||
be unavailable.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
@@ -135,7 +135,7 @@ export function RegistrationSection({
|
||||
|
||||
<KeyValue
|
||||
label="Created"
|
||||
value={formatDate(data.creationDate || "")}
|
||||
value={formatDate(data.creationDate || "Unknown")}
|
||||
valueTooltip={
|
||||
data.creationDate
|
||||
? formatDateTimeUtc(data.creationDate)
|
||||
@@ -145,7 +145,7 @@ export function RegistrationSection({
|
||||
|
||||
<KeyValue
|
||||
label="Expires"
|
||||
value={formatDate(data.expirationDate || "")}
|
||||
value={formatDate(data.expirationDate || "Unknown")}
|
||||
valueTooltip={
|
||||
data.expirationDate
|
||||
? formatDateTimeUtc(data.expirationDate)
|
||||
|
@@ -80,6 +80,7 @@ export function SeoSection({
|
||||
{ label: "Robots", value: data?.meta?.general.robots },
|
||||
];
|
||||
const metaTagCount = metaTagValues.filter((t) => t.value != null).length;
|
||||
const hasAnySeoMeta = metaTagCount > 0;
|
||||
|
||||
// Decide which X (Twitter) card variant to display based on meta tags.
|
||||
const twitterCard = data?.meta?.twitter?.card?.toLowerCase();
|
||||
@@ -100,7 +101,18 @@ export function SeoSection({
|
||||
<Section {...SECTION_DEFS.seo} isError={isError} isLoading={isLoading}>
|
||||
{isLoading ? (
|
||||
<SeoSkeleton />
|
||||
) : data ? (
|
||||
) : isError ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Failed to load SEO analysis.
|
||||
<button
|
||||
onClick={onRetryAction}
|
||||
className="ml-2 underline underline-offset-2"
|
||||
type="button"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : data && hasAnySeoMeta ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-[11px] text-foreground/70 uppercase leading-none tracking-[0.08em] dark:text-foreground/80">
|
||||
@@ -211,18 +223,20 @@ export function SeoSection({
|
||||
|
||||
<RobotsSummary robots={data.robots} />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Failed to load SEO analysis.
|
||||
<button
|
||||
onClick={onRetryAction}
|
||||
className="ml-2 underline underline-offset-2"
|
||||
type="button"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<Empty className="border border-dashed">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FileQuestionMark />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No SEO meta detected</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
We didn't find standard SEO meta tags (title, description,
|
||||
canonical, or open graph). Add them to improve link previews.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -797,7 +811,10 @@ function SeoSkeleton() {
|
||||
<div className="space-y-4">
|
||||
{/* Meta Tags */}
|
||||
<div className="space-y-3">
|
||||
<SubheadCountSkeleton />
|
||||
<div className="flex items-center gap-2 text-[11px] text-foreground/70 uppercase leading-none tracking-[0.08em] dark:text-foreground/80">
|
||||
Meta Tags
|
||||
<SubheadCountSkeleton />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<KeyValueSkeleton label="Title" widthClass="w-[220px]" />
|
||||
<KeyValueSkeleton label="Description" widthClass="w-[260px]" />
|
||||
@@ -829,7 +846,7 @@ function SeoSkeleton() {
|
||||
{/* Robots summary */}
|
||||
<div className="space-y-4 rounded-xl">
|
||||
<div className="mt-5 flex items-center gap-2 text-[11px] text-foreground/70 uppercase leading-none tracking-[0.08em] dark:text-foreground/80">
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
robots.txt
|
||||
<SubheadCountSkeleton />
|
||||
</div>
|
||||
|
||||
@@ -852,7 +869,8 @@ function SeoSkeleton() {
|
||||
|
||||
{/* Sitemaps */}
|
||||
<div className="space-y-3">
|
||||
<div className="mt-5">
|
||||
<div className="mt-5 flex items-center gap-2 text-[11px] text-foreground/70 uppercase leading-none tracking-[0.08em] dark:text-foreground/80">
|
||||
Sitemaps
|
||||
<SubheadCountSkeleton />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
@@ -35,7 +35,10 @@ export function useDomainQueries(domain: string) {
|
||||
trpc.domain.hosting.queryOptions(
|
||||
{ domain },
|
||||
{
|
||||
enabled: registration.data?.isRegistered,
|
||||
// Optional micro-tuning: wait until DNS has resolved once to better
|
||||
// reuse warm caches server-side. If DNS errored, still allow hosting to run.
|
||||
enabled:
|
||||
registration.data?.isRegistered && (dns.isSuccess || dns.isError),
|
||||
staleTime: 30 * 60_000, // 30 minutes
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
|
@@ -159,4 +159,52 @@ describe("resolveAll", () => {
|
||||
expect(["cloudflare", "google"]).toContain(second.resolver);
|
||||
secondFetch.mockRestore();
|
||||
});
|
||||
|
||||
it("dedupes concurrent callers via aggregate cache/lock", async () => {
|
||||
globalThis.__redisTestHelper?.reset();
|
||||
// Prepare one set of responses for provider 1 across types
|
||||
const dohAnswer = (
|
||||
answers: Array<{ name: string; TTL: number; data: string }>,
|
||||
) =>
|
||||
new Response(JSON.stringify({ Status: 0, Answer: answers }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/dns-json" },
|
||||
});
|
||||
|
||||
const fetchMock = vi
|
||||
.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
dohAnswer([{ name: "example.com.", TTL: 60, data: "1.2.3.4" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
dohAnswer([{ name: "example.com.", TTL: 60, data: "::1" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
dohAnswer([
|
||||
{ name: "example.com.", TTL: 300, data: "10 aspmx.l.google.com." },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
dohAnswer([{ name: "example.com.", TTL: 120, data: '"v=spf1"' }]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
dohAnswer([
|
||||
{ name: "example.com.", TTL: 600, data: "ns1.cloudflare.com." },
|
||||
]),
|
||||
);
|
||||
|
||||
// Fire several concurrent calls
|
||||
const [r1, r2, r3] = await Promise.all([
|
||||
resolveAll("example.com"),
|
||||
resolveAll("example.com"),
|
||||
resolveAll("example.com"),
|
||||
]);
|
||||
|
||||
expect(r1.records.length).toBeGreaterThan(0);
|
||||
expect(r2.records.length).toBe(r1.records.length);
|
||||
expect(r3.records.length).toBe(r1.records.length);
|
||||
// Only 5 DoH fetches should have occurred for the initial provider/types
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
});
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { captureServer } from "@/lib/analytics/server";
|
||||
import { isCloudflareIpAsync } from "@/lib/cloudflare";
|
||||
import { USER_AGENT } from "@/lib/constants";
|
||||
import { ns, redis } from "@/lib/redis";
|
||||
import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis";
|
||||
import {
|
||||
type DnsRecord,
|
||||
type DnsResolveResult,
|
||||
@@ -51,6 +51,120 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
|
||||
const providers = providerOrderForLookup(lower);
|
||||
const durationByProvider: Record<string, number> = {};
|
||||
let lastError: unknown = null;
|
||||
const aggregateKey = ns("dns:all", lower);
|
||||
const lockKey = ns("lock", `dns:${lower}`);
|
||||
|
||||
// Aggregate cache fast-path
|
||||
try {
|
||||
const agg = (await redis.get(aggregateKey)) as DnsResolveResult | null;
|
||||
if (agg && Array.isArray(agg.records)) {
|
||||
await captureServer("dns_resolve_all", {
|
||||
domain: lower,
|
||||
duration_ms_total: Date.now() - startedAt,
|
||||
counts: ((): Record<DnsType, number> => {
|
||||
return (DnsTypeSchema.options as DnsType[]).reduce(
|
||||
(acc, t) => {
|
||||
acc[t] = agg.records.filter((r) => r.type === t).length;
|
||||
return acc;
|
||||
},
|
||||
{ A: 0, AAAA: 0, MX: 0, TXT: 0, NS: 0 } as Record<DnsType, number>,
|
||||
);
|
||||
})(),
|
||||
cloudflare_ip_present: agg.records.some(
|
||||
(r) => (r.type === "A" || r.type === "AAAA") && r.isCloudflare,
|
||||
),
|
||||
dns_provider_used: agg.resolver,
|
||||
provider_attempts: 0,
|
||||
duration_ms_by_provider: {},
|
||||
cache_hit: true,
|
||||
cache_source: "aggregate",
|
||||
});
|
||||
console.info("[dns] aggregate cache hit", {
|
||||
domain: lower,
|
||||
resolver: agg.resolver,
|
||||
total: agg.records.length,
|
||||
});
|
||||
return agg;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Try to acquire lock or wait for someone else's result
|
||||
const lockWaitStart = Date.now();
|
||||
const lockResult = await acquireLockOrWaitForResult<DnsResolveResult>({
|
||||
lockKey,
|
||||
resultKey: aggregateKey,
|
||||
lockTtl: 30,
|
||||
});
|
||||
if (!lockResult.acquired && lockResult.cachedResult) {
|
||||
const agg = lockResult.cachedResult;
|
||||
await captureServer("dns_resolve_all", {
|
||||
domain: lower,
|
||||
duration_ms_total: Date.now() - startedAt,
|
||||
counts: ((): Record<DnsType, number> => {
|
||||
return (DnsTypeSchema.options as DnsType[]).reduce(
|
||||
(acc, t) => {
|
||||
acc[t] = agg.records.filter((r) => r.type === t).length;
|
||||
return acc;
|
||||
},
|
||||
{ A: 0, AAAA: 0, MX: 0, TXT: 0, NS: 0 } as Record<DnsType, number>,
|
||||
);
|
||||
})(),
|
||||
cloudflare_ip_present: agg.records.some(
|
||||
(r) => (r.type === "A" || r.type === "AAAA") && r.isCloudflare,
|
||||
),
|
||||
dns_provider_used: agg.resolver,
|
||||
provider_attempts: 0,
|
||||
duration_ms_by_provider: {},
|
||||
cache_hit: true,
|
||||
cache_source: "aggregate_wait",
|
||||
lock_acquired: false,
|
||||
lock_waited_ms: Date.now() - lockWaitStart,
|
||||
});
|
||||
console.info("[dns] waited for aggregate", { domain: lower });
|
||||
return agg;
|
||||
}
|
||||
const acquiredLock = lockResult.acquired;
|
||||
if (!acquiredLock && !lockResult.cachedResult) {
|
||||
// Manual short wait/poll for aggregate result in test envs where
|
||||
// acquireLockOrWaitForResult does not poll. Keeps callers from duplicating work.
|
||||
const start = Date.now();
|
||||
const maxWaitMs = 1500;
|
||||
const intervalMs = 25;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (Date.now() - start < maxWaitMs) {
|
||||
const agg = (await redis.get(aggregateKey)) as DnsResolveResult | null;
|
||||
if (agg && Array.isArray(agg.records)) {
|
||||
await captureServer("dns_resolve_all", {
|
||||
domain: lower,
|
||||
duration_ms_total: Date.now() - startedAt,
|
||||
counts: ((): Record<DnsType, number> => {
|
||||
return (DnsTypeSchema.options as DnsType[]).reduce(
|
||||
(acc, t) => {
|
||||
acc[t] = agg.records.filter((r) => r.type === t).length;
|
||||
return acc;
|
||||
},
|
||||
{ A: 0, AAAA: 0, MX: 0, TXT: 0, NS: 0 } as Record<
|
||||
DnsType,
|
||||
number
|
||||
>,
|
||||
);
|
||||
})(),
|
||||
cloudflare_ip_present: agg.records.some(
|
||||
(r) => (r.type === "A" || r.type === "AAAA") && r.isCloudflare,
|
||||
),
|
||||
dns_provider_used: agg.resolver,
|
||||
provider_attempts: 0,
|
||||
duration_ms_by_provider: {},
|
||||
cache_hit: true,
|
||||
cache_source: "aggregate_wait",
|
||||
lock_acquired: false,
|
||||
lock_waited_ms: Date.now() - start,
|
||||
});
|
||||
return agg;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Provider-agnostic cache check: if all types are cached, return immediately
|
||||
const types = DnsTypeSchema.options;
|
||||
@@ -76,6 +190,15 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
|
||||
((await redis.get(
|
||||
ns("dns:meta", `${lower}:resolver`),
|
||||
)) as DnsResolver | null) || "cloudflare";
|
||||
try {
|
||||
await redis.set(
|
||||
aggregateKey,
|
||||
{ records: flat, resolver: resolverUsed },
|
||||
{
|
||||
ex: 5 * 60,
|
||||
},
|
||||
);
|
||||
} catch {}
|
||||
await captureServer("dns_resolve_all", {
|
||||
domain: lower,
|
||||
duration_ms_total: Date.now() - startedAt,
|
||||
@@ -85,12 +208,20 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
|
||||
provider_attempts: 0,
|
||||
duration_ms_by_provider: {},
|
||||
cache_hit: true,
|
||||
cache_source: "per_type",
|
||||
lock_acquired: acquiredLock,
|
||||
lock_waited_ms: acquiredLock ? 0 : Date.now() - lockWaitStart,
|
||||
});
|
||||
console.info("[dns] cache hit", {
|
||||
domain: lower,
|
||||
counts,
|
||||
resolver: resolverUsed,
|
||||
});
|
||||
if (acquiredLock) {
|
||||
try {
|
||||
await redis.del(lockKey);
|
||||
} catch {}
|
||||
}
|
||||
return { records: flat, resolver: resolverUsed } as DnsResolveResult;
|
||||
}
|
||||
|
||||
@@ -134,6 +265,15 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
|
||||
: ((await redis.get(
|
||||
ns("dns:meta", `${lower}:resolver`),
|
||||
)) as DnsResolver | null) || provider.key;
|
||||
try {
|
||||
await redis.set(
|
||||
aggregateKey,
|
||||
{ records: flat, resolver: resolverUsed },
|
||||
{
|
||||
ex: 5 * 60,
|
||||
},
|
||||
);
|
||||
} catch {}
|
||||
await captureServer("dns_resolve_all", {
|
||||
domain: lower,
|
||||
duration_ms_total: Date.now() - startedAt,
|
||||
@@ -143,6 +283,9 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
|
||||
provider_attempts: attemptIndex + 1,
|
||||
duration_ms_by_provider: durationByProvider,
|
||||
cache_hit: !usedFresh,
|
||||
cache_source: usedFresh ? "fresh" : "per_type",
|
||||
lock_acquired: acquiredLock,
|
||||
lock_waited_ms: acquiredLock ? 0 : Date.now() - lockWaitStart,
|
||||
});
|
||||
console.info("[dns] ok", {
|
||||
domain: lower,
|
||||
@@ -150,6 +293,11 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
|
||||
resolver: resolverUsed,
|
||||
duration_ms_total: Date.now() - startedAt,
|
||||
});
|
||||
if (acquiredLock) {
|
||||
try {
|
||||
await redis.del(lockKey);
|
||||
} catch {}
|
||||
}
|
||||
return { records: flat, resolver: resolverUsed } as DnsResolveResult;
|
||||
} catch (err) {
|
||||
console.warn("[dns] provider attempt failed", {
|
||||
|
@@ -52,6 +52,34 @@ describe("probeHeaders", () => {
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it("dedupes concurrent callers via lock/wait", async () => {
|
||||
const head = new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
server: "vercel",
|
||||
"x-vercel-id": "abc",
|
||||
},
|
||||
});
|
||||
const fetchMock = vi
|
||||
.spyOn(global, "fetch")
|
||||
.mockImplementation(async (_url, init?: RequestInit) => {
|
||||
if ((init?.method || "HEAD") === "HEAD") return head;
|
||||
return new Response(null, { status: 500 });
|
||||
});
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
probeHeaders("example.com"),
|
||||
probeHeaders("example.com"),
|
||||
probeHeaders("example.com"),
|
||||
]);
|
||||
expect(a.length).toBeGreaterThan(0);
|
||||
expect(b.length).toBe(a.length);
|
||||
expect(c.length).toBe(a.length);
|
||||
// HEAD called once; no GETs should be needed after first completes
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it("returns empty array and does not cache on error", async () => {
|
||||
const fetchMock = vi.spyOn(global, "fetch").mockImplementation(async () => {
|
||||
throw new Error("network");
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import { captureServer } from "@/lib/analytics/server";
|
||||
import { ns, redis } from "@/lib/redis";
|
||||
import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis";
|
||||
import type { HttpHeader } from "@/lib/schemas";
|
||||
|
||||
export async function probeHeaders(domain: string): Promise<HttpHeader[]> {
|
||||
const lower = domain.toLowerCase();
|
||||
const url = `https://${domain}/`;
|
||||
const key = ns("headers", lower);
|
||||
const lockKey = ns("lock", `headers:${lower}`);
|
||||
|
||||
console.debug("[headers] start", { domain: lower });
|
||||
const cached = await redis.get<HttpHeader[]>(key);
|
||||
@@ -17,6 +18,34 @@ export async function probeHeaders(domain: string): Promise<HttpHeader[]> {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Try to acquire lock or wait for someone else's result
|
||||
const lockWaitStart = Date.now();
|
||||
const lockResult = await acquireLockOrWaitForResult<HttpHeader[]>({
|
||||
lockKey,
|
||||
resultKey: key,
|
||||
lockTtl: 30,
|
||||
});
|
||||
if (!lockResult.acquired && Array.isArray(lockResult.cachedResult)) {
|
||||
return lockResult.cachedResult;
|
||||
}
|
||||
const acquiredLock = lockResult.acquired;
|
||||
if (!acquiredLock && !lockResult.cachedResult) {
|
||||
// Short poll for cached result to avoid duplicate external requests when the
|
||||
// helper cannot poll in the current environment
|
||||
const start = Date.now();
|
||||
const maxWaitMs = 1500;
|
||||
const intervalMs = 25;
|
||||
while (Date.now() - start < maxWaitMs) {
|
||||
const result = (await redis.get<HttpHeader[]>(key)) as
|
||||
| HttpHeader[]
|
||||
| null;
|
||||
if (Array.isArray(result)) {
|
||||
return result;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
try {
|
||||
// Try HEAD first with timeout
|
||||
@@ -67,6 +96,8 @@ export async function probeHeaders(domain: string): Promise<HttpHeader[]> {
|
||||
status: final.status,
|
||||
used_method: res?.ok ? "HEAD" : "GET",
|
||||
final_url: final.url,
|
||||
lock_acquired: acquiredLock,
|
||||
lock_waited_ms: acquiredLock ? 0 : Date.now() - lockWaitStart,
|
||||
});
|
||||
|
||||
await redis.set(key, normalized, { ex: 10 * 60 });
|
||||
@@ -75,6 +106,11 @@ export async function probeHeaders(domain: string): Promise<HttpHeader[]> {
|
||||
status: final.status,
|
||||
count: normalized.length,
|
||||
});
|
||||
if (acquiredLock) {
|
||||
try {
|
||||
await redis.del(lockKey);
|
||||
} catch {}
|
||||
}
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
console.warn("[headers] error", {
|
||||
@@ -87,8 +123,15 @@ export async function probeHeaders(domain: string): Promise<HttpHeader[]> {
|
||||
used_method: "ERROR",
|
||||
final_url: url,
|
||||
error: String(err),
|
||||
lock_acquired: acquiredLock,
|
||||
lock_waited_ms: acquiredLock ? 0 : Date.now() - lockWaitStart,
|
||||
});
|
||||
// Return empty on failure without caching to avoid long-lived negatives
|
||||
if (acquiredLock) {
|
||||
try {
|
||||
await redis.del(lockKey);
|
||||
} catch {}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user