refactor: remove date-fns dependency and replace with domainstack/utils

- Removed all instances of `date-fns` from the codebase, replacing its usage with utility functions from `@domainstack/utils/date` for date formatting and calculations.
- Updated components and workflows to utilize the new date formatting methods, ensuring consistent date handling across the application.
This commit is contained in:
2026-09-10 11:01:01 -04:00
parent 017d493984
commit c849842788
40 changed files with 603 additions and 405 deletions
@@ -1,4 +1,3 @@
import { subDays } from "date-fns";
import { NextResponse } from "next/server";
import { deleteStaleUnverifiedDomainsByCutoff } from "@domainstack/db/queries/tracked-domains";
@@ -8,6 +7,7 @@ const logger = createLogger({ source: "cron/cleanup-stale-domains" });
// Domains that remain unverified after this many days will be deleted
const STALE_DOMAIN_DAYS = 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Cron job to clean up stale unverified domains.
@@ -25,7 +25,9 @@ export async function GET(request: Request) {
try {
logger.info("Starting cleanup stale domains cron job");
const cutoffDate = subDays(new Date(), STALE_DOMAIN_DAYS);
// Absolute rather than calendar days: an hour either side of a clock
// change is immaterial to a month-long staleness cutoff.
const cutoffDate = new Date(Date.now() - STALE_DOMAIN_DAYS * MS_PER_DAY);
// Optimized: Delete directly by cutoff date in a single query
const deletedCount = await deleteStaleUnverifiedDomainsByCutoff(cutoffDate);
@@ -4,6 +4,7 @@ import { createMcpHandler } from "mcp-handler";
import { PostHog } from "posthog-node";
import { z } from "zod";
import { type Section, SECTION_IDS } from "@/lib/constants/sections";
import { checkRateLimit } from "@/lib/ratelimit/api";
import { createCaller } from "@/server/routers/_app";
import type { Context } from "@/trpc/init";
@@ -27,22 +28,8 @@ const domainSchema = z.object({
domain: z.string().min(1, "Domain is required"),
});
/**
* Available sections for domain_report bundle tool.
*/
const REPORT_SECTIONS = [
"dns",
"registration",
"hosting",
"certificates",
"headers",
"seo",
] as const;
type ReportSection = (typeof REPORT_SECTIONS)[number];
const sectionsSchema = z
.array(z.enum(REPORT_SECTIONS))
.array(z.enum(SECTION_IDS))
.optional()
.describe("Sections to include in the report. If omitted, all sections are included.");
@@ -225,12 +212,12 @@ function createMcpHandlerWithContext(request: Request) {
},
async ({ domain, sections }) => {
// Default to all sections if not specified
const requestedSections: ReportSection[] =
sections && sections.length > 0 ? sections : [...REPORT_SECTIONS];
const requestedSections: Section[] =
sections && sections.length > 0 ? sections : [...SECTION_IDS];
// Define section fetchers
const sectionFetchers: Record<
ReportSection,
Section,
() => Promise<{ success: boolean; data?: unknown; error?: string }>
> = {
registration: () => trpc.domain.getRegistration({ domain }),
@@ -8,7 +8,6 @@ import {
IconRefresh,
IconShieldLock,
} from "@tabler/icons-react";
import { formatDistanceStrict } from "date-fns";
import { useCallback, useState } from "react";
import { useCalendarFeed } from "@/hooks/use-calendar-feed";
@@ -35,7 +34,7 @@ import {
} from "@domainstack/ui/dropdown-menu";
import { Skeleton } from "@domainstack/ui/skeleton";
import { Spinner } from "@domainstack/ui/spinner";
import { toDateTimeAttr } from "@domainstack/utils/date";
import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date";
/**
* Outlook doesn't have an icon in @icons-pack/react-simple-icons, so we draw
@@ -140,11 +139,7 @@ export function CalendarInstructions({ className }: { className?: string }) {
<span suppressHydrationWarning>
Last accessed{" "}
<time dateTime={lastAccessedDateTime} suppressHydrationWarning>
{now
? formatDistanceStrict(new Date(lastAccessedDateTime), now, {
addSuffix: true,
})
: "…"}
{now ? formatRelativeTime(new Date(lastAccessedDateTime), now) : "…"}
</time>
</span>
) : (
@@ -1,5 +1,4 @@
import { IconArchive, IconCircleArrowUp, IconRefresh, IconTrash } from "@tabler/icons-react";
import { formatDistanceStrict } from "date-fns";
import { DashboardBannerDismissable } from "@/components/dashboard/dashboard-banner-dismissable";
import { Favicon } from "@/components/icons/favicon";
@@ -18,7 +17,7 @@ import {
} from "@domainstack/ui/empty";
import { Tooltip, TooltipContent, TooltipTrigger } from "@domainstack/ui/tooltip";
import { cn } from "@domainstack/ui/utils";
import { toDateTimeAttr } from "@domainstack/utils/date";
import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date";
type ArchivedDomainsListProps = {
domains: TrackedDomainWithDetails[];
@@ -122,7 +121,7 @@ function ArchivedRelativeTime({ archivedAt }: { archivedAt: Date | string | null
return <span>recently</span>;
}
const label = now ? formatDistanceStrict(new Date(dateTime), now, { addSuffix: true }) : null;
const label = now ? formatRelativeTime(new Date(dateTime), now) : null;
return (
<time
@@ -5,14 +5,13 @@ import {
IconQuestionMark,
type TablerIcon,
} from "@tabler/icons-react";
import { formatDistanceStrict } from "date-fns";
import { useMemo } from "react";
import { BadgeWithTooltip } from "@/components/dashboard/badge-with-tooltip";
import { useHydratedNow } from "@/hooks/use-hydrated-now";
import { getHealthSeverity, type HealthSeverity } from "@/lib/dashboard-utils";
import { cn } from "@domainstack/ui/utils";
import { toDateTimeAttr } from "@domainstack/utils/date";
import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date";
type DomainHealthBadgeProps = {
expirationDate: Date | null;
@@ -34,9 +33,7 @@ export function DomainHealthBadge({ expirationDate, verified, className }: Domai
if (!dateTime || !now) return null;
const expiration = new Date(dateTime);
const isExpired = expiration <= now;
const relativeTime = formatDistanceStrict(expiration, now, {
addSuffix: true,
});
const relativeTime = formatRelativeTime(expiration, now);
return `${isExpired ? "Expired" : "Expires"} ${relativeTime}`;
}, [dateTime, now]);
@@ -4,13 +4,13 @@ import {
IconRosetteDiscountCheck,
type TablerIcon,
} from "@tabler/icons-react";
import { differenceInDays } from "date-fns";
import { BadgeWithTooltip } from "@/components/dashboard/badge-with-tooltip";
import { useHydratedNow } from "@/hooks/use-hydrated-now";
import { VERIFICATION_GRACE_PERIOD_DAYS } from "@domainstack/constants";
import type { VerificationMethod, VerificationStatus } from "@domainstack/types";
import { cn } from "@domainstack/ui/utils";
import { calculateDaysElapsed } from "@domainstack/utils/expiry";
type DomainStatusBadgeProps = {
verified: boolean;
@@ -41,7 +41,10 @@ function getFailingTooltip(
): string {
const daysRemaining =
verificationFailedAt && now
? Math.max(0, VERIFICATION_GRACE_PERIOD_DAYS - differenceInDays(now, verificationFailedAt))
? Math.max(
0,
VERIFICATION_GRACE_PERIOD_DAYS - calculateDaysElapsed(verificationFailedAt, now),
)
: VERIFICATION_GRACE_PERIOD_DAYS;
if (daysRemaining > 0) {
@@ -1,11 +1,11 @@
import { IconCalendarDot } from "@tabler/icons-react";
import { differenceInDays, formatDistanceStrict } from "date-fns";
import { DashboardBannerDismissable } from "@/components/dashboard/dashboard-banner-dismissable";
import { useHydratedNow } from "@/hooks/use-hydrated-now";
import { useSubscription } from "@/hooks/use-subscription";
import { PLAN_QUOTAS } from "@domainstack/constants";
import { formatDate, toDateTimeAttr } from "@domainstack/utils/date";
import { formatDate, formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date";
import { calculateDaysRemaining } from "@domainstack/utils/expiry";
export function SubscriptionEndingBanner() {
const { handleCheckout, isCheckoutLoading, handleCustomerPortal, isCustomerPortalLoading } =
@@ -25,11 +25,9 @@ export function SubscriptionEndingBanner() {
const isExpired = subscription.endsAt < now;
if (isExpired) return null;
const daysRemaining = differenceInDays(subscription.endsAt, now);
const daysRemaining = calculateDaysRemaining(subscription.endsAt, now);
const formattedDate = formatDate(subscription.endsAt);
const relativeTime = formatDistanceStrict(subscription.endsAt, now, {
addSuffix: true,
});
const relativeTime = formatRelativeTime(subscription.endsAt, now);
// Determine urgency based on days remaining
const isUrgent = daysRemaining <= 3;
+2 -3
View File
@@ -1,11 +1,10 @@
"use client";
import { formatDistanceStrict } from "date-fns";
import { useMemo } from "react";
import { RelativeTimeSuffix } from "@/components/domain/relative-time-suffix";
import { useHydratedNow } from "@/hooks/use-hydrated-now";
import { toDateTimeAttr } from "@domainstack/utils/date";
import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date";
export function RelativeAgeString({
from,
@@ -21,7 +20,7 @@ export function RelativeAgeString({
const text = useMemo(() => {
if (!now || !dateTime) return null;
return formatDistanceStrict(new Date(dateTime), now, { addSuffix: true });
return formatRelativeTime(new Date(dateTime), now) ?? null;
}, [dateTime, now]);
return <RelativeTimeSuffix dateTime={dateTime} text={text} className={className} />;
@@ -1,12 +1,12 @@
"use client";
import { differenceInDays, formatDistanceStrict } from "date-fns";
import { useMemo } from "react";
import { RelativeTimeSuffix } from "@/components/domain/relative-time-suffix";
import { useHydratedNow } from "@/hooks/use-hydrated-now";
import { cn } from "@domainstack/ui/utils";
import { toDateTimeAttr } from "@domainstack/utils/date";
import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date";
import { calculateDaysRemaining } from "@domainstack/utils/expiry";
export function RelativeExpiryString({
to,
@@ -30,8 +30,8 @@ export function RelativeExpiryString({
if (!now || !dateTime) return null;
const targetDate = new Date(dateTime);
return {
text: formatDistanceStrict(targetDate, now, { addSuffix: true }),
daysUntil: differenceInDays(targetDate, now),
text: formatRelativeTime(targetDate, now),
daysUntil: calculateDaysRemaining(targetDate, now),
};
}, [dateTime, now]);
@@ -1,6 +1,5 @@
"use client";
import { formatDistanceStrict } from "date-fns";
import Link from "next/link";
import { createElement } from "react";
@@ -14,7 +13,7 @@ import {
import type { NotificationData } from "@domainstack/types";
import { Icon } from "@domainstack/ui/icon";
import { cn } from "@domainstack/ui/utils";
import { toDateTimeAttr } from "@domainstack/utils/date";
import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date";
interface NotificationCardProps {
notification: NotificationData;
@@ -69,11 +68,7 @@ export function NotificationCard({ notification, onClick }: NotificationCardProp
<p className="mt-1 text-xs text-muted-foreground/75">
{sentAtDateTime ? (
<time dateTime={sentAtDateTime} suppressHydrationWarning>
{now
? formatDistanceStrict(new Date(sentAtDateTime), now, {
addSuffix: true,
})
: "…"}
{now ? formatRelativeTime(new Date(sentAtDateTime), now) : "…"}
</time>
) : null}
</p>
-33
View File
@@ -7,8 +7,6 @@ import {
getDomainToolErrorMessage,
getDomainToolStatus,
getToolPartType,
getTrpcErrorCode,
isExpectedDomainToolError,
type DomainToolResult,
} from "./domain-tools";
@@ -57,37 +55,6 @@ describe("getDomainToolStatus", () => {
});
});
describe("getTrpcErrorCode", () => {
it("returns a direct tRPC code", () => {
expect(getTrpcErrorCode({ code: "BAD_REQUEST" })).toBe("BAD_REQUEST");
});
it("returns a nested data.code tRPC code", () => {
expect(getTrpcErrorCode({ data: { code: "TOO_MANY_REQUESTS" } })).toBe("TOO_MANY_REQUESTS");
});
it("returns undefined for non-object inputs", () => {
expect(getTrpcErrorCode("BAD_REQUEST")).toBeUndefined();
expect(getTrpcErrorCode(null)).toBeUndefined();
});
it("ignores generic string codes that are not tRPC codes", () => {
expect(getTrpcErrorCode({ code: "ENOTFOUND" })).toBeUndefined();
});
});
describe("isExpectedDomainToolError", () => {
it("treats validation and rate-limit failures as expected", () => {
expect(isExpectedDomainToolError({ code: "BAD_REQUEST" })).toBe(true);
expect(isExpectedDomainToolError({ data: { code: "TOO_MANY_REQUESTS" } })).toBe(true);
});
it("treats internal and unknown failures as unexpected", () => {
expect(isExpectedDomainToolError({ code: "INTERNAL_SERVER_ERROR" })).toBe(false);
expect(isExpectedDomainToolError(new Error("boom"))).toBe(false);
});
});
describe("getDomainToolErrorMessage", () => {
it("returns the TOO_MANY_REQUESTS message", () => {
expect(getDomainToolErrorMessage({ code: "TOO_MANY_REQUESTS" })).toBe(
+1 -56
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { getTrpcErrorCode } from "@/lib/trpc/errors";
import type {
CertificatesResponse,
DnsRecordsResponse,
@@ -86,30 +87,6 @@ const DOMAIN_TOOL_STATUS = Object.fromEntries(
DOMAIN_TOOL_DEFS.map((def) => [def.name, def.status]),
) as Record<DomainToolName, string>;
const TRPC_ERROR_CODES = new Set([
"PARSE_ERROR",
"BAD_REQUEST",
"INTERNAL_SERVER_ERROR",
"NOT_IMPLEMENTED",
"BAD_GATEWAY",
"SERVICE_UNAVAILABLE",
"GATEWAY_TIMEOUT",
"UNAUTHORIZED",
"PAYMENT_REQUIRED",
"FORBIDDEN",
"NOT_FOUND",
"METHOD_NOT_SUPPORTED",
"TIMEOUT",
"CONFLICT",
"PRECONDITION_FAILED",
"PAYLOAD_TOO_LARGE",
"UNSUPPORTED_MEDIA_TYPE",
"UNPROCESSABLE_CONTENT",
"PRECONDITION_REQUIRED",
"TOO_MANY_REQUESTS",
"CLIENT_CLOSED_REQUEST",
]);
export type DomainToolInput = z.infer<typeof domainToolInputSchema>;
/**
@@ -133,38 +110,6 @@ export function getDomainToolStatus(type: string): string {
return DOMAIN_TOOL_STATUS[toolName as DomainToolName] ?? toolName;
}
function asTrpcErrorCode(code: unknown): string | undefined {
return typeof code === "string" && TRPC_ERROR_CODES.has(code) ? code : undefined;
}
/**
* tRPC failures the model can report to the user (validation, rate limits).
* Unexpected / internal errors should be retried by the workflow step instead.
*/
export function isExpectedDomainToolError(err: unknown): boolean {
const code = getTrpcErrorCode(err);
return code != null && code !== "INTERNAL_SERVER_ERROR";
}
export function getTrpcErrorCode(err: unknown): string | undefined {
if (typeof err !== "object" || err === null) {
return undefined;
}
if ("code" in err) {
const code = asTrpcErrorCode(err.code);
if (code) {
return code;
}
}
if ("data" in err && typeof err.data === "object" && err.data !== null && "code" in err.data) {
return asTrpcErrorCode(err.data.code);
}
return undefined;
}
export function getDomainToolErrorMessage(err: unknown): string {
const code = getTrpcErrorCode(err);
if (code === "TOO_MANY_REQUESTS") {
+15 -1
View File
@@ -11,7 +11,21 @@ import {
* Section types and metadata for domain report UI.
*/
export type Section = "dns" | "headers" | "hosting" | "certificates" | "seo" | "registration";
/**
* Every report section, in the order the report renders them. The `Section`
* union and the `sections` record below both derive from this, so anything that
* needs the full list at runtime can import it instead of retyping it.
*/
export const SECTION_IDS = [
"registration",
"hosting",
"dns",
"certificates",
"headers",
"seo",
] as const;
export type Section = (typeof SECTION_IDS)[number];
export type SectionAccent = "blue" | "purple" | "green" | "orange" | "pink" | "cyan";
+2 -5
View File
@@ -1,7 +1,7 @@
import type { SortingState } from "@tanstack/react-table";
import { EXPIRING_CRITICAL_DAYS, EXPIRING_SOON_DAYS } from "@domainstack/constants";
import type { TrackedDomainWithDetails } from "@domainstack/types";
import type { ProviderCategory, TrackedDomainWithDetails } from "@domainstack/types";
import { calculateDaysRemaining } from "@domainstack/utils/expiry";
// ---------------------------------------------------------------------------
@@ -14,10 +14,7 @@ export interface AvailableProvider {
domain: string | null;
}
export type AvailableProvidersByCategory = Record<
"registrar" | "dns" | "hosting" | "email" | "ca",
AvailableProvider[]
>;
export type AvailableProvidersByCategory = Record<ProviderCategory, AvailableProvider[]>;
/** Filter types for domain verification status */
export type StatusFilter = "verified" | "pending";
+56
View File
@@ -0,0 +1,56 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import { getTrpcErrorCode, isExpectedTrpcError } from "./errors";
describe("getTrpcErrorCode", () => {
it("returns a direct tRPC code", () => {
expect(getTrpcErrorCode({ code: "BAD_REQUEST" })).toBe("BAD_REQUEST");
});
it("returns a nested data.code tRPC code", () => {
expect(getTrpcErrorCode({ data: { code: "TOO_MANY_REQUESTS" } })).toBe("TOO_MANY_REQUESTS");
});
it("prefers the direct code over a nested one", () => {
expect(getTrpcErrorCode({ code: "CONFLICT", data: { code: "NOT_FOUND" } })).toBe("CONFLICT");
});
it("returns undefined for non-object inputs", () => {
expect(getTrpcErrorCode("BAD_REQUEST")).toBeUndefined();
expect(getTrpcErrorCode(null)).toBeUndefined();
});
it("ignores generic string codes that are not tRPC codes", () => {
expect(getTrpcErrorCode({ code: "ENOTFOUND" })).toBeUndefined();
expect(getTrpcErrorCode({ code: "ECONNREFUSED" })).toBeUndefined();
});
});
describe("isExpectedTrpcError", () => {
it("treats validation and rate-limit failures as expected", () => {
expect(isExpectedTrpcError({ code: "BAD_REQUEST" })).toBe(true);
expect(isExpectedTrpcError({ data: { code: "TOO_MANY_REQUESTS" } })).toBe(true);
expect(isExpectedTrpcError({ code: "NOT_FOUND" })).toBe(true);
});
it("treats transient server failures as unexpected so callers retry them", () => {
// The chat tools and the query client disagreed about these: one reported
// them to the user as final while the other retried. They retry now.
for (const code of [
"INTERNAL_SERVER_ERROR",
"NOT_IMPLEMENTED",
"BAD_GATEWAY",
"SERVICE_UNAVAILABLE",
"GATEWAY_TIMEOUT",
"TIMEOUT",
]) {
expect(isExpectedTrpcError({ code })).toBe(false);
}
});
it("treats errors with no tRPC code as unexpected", () => {
expect(isExpectedTrpcError(new Error("boom"))).toBe(false);
expect(isExpectedTrpcError({ code: "ECONNREFUSED" })).toBe(false);
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* Shared classification of tRPC error codes.
*
* Both the TanStack Query client and the chat tool runner have to answer the
* same question about a failed procedure call: did the caller cause this, or is
* it a transient server-side failure worth retrying? They used to answer it
* with separate lists that disagreed about the gateway and timeout codes, so
* one retried them and the other reported them to the user as final.
*/
/**
* Codes that mean "try again": the request itself was fine, the server or a
* dependency was momentarily unavailable. Everything else in
* {@link TRPC_ERROR_CODES} is treated as caller-caused.
*
* Listing the retryable side rather than the expected side means a code added
* to tRPC later defaults to caller-caused, which fails safe: we surface it
* instead of hammering an endpoint that will keep rejecting us.
*/
const RETRYABLE_TRPC_ERROR_CODES = new Set([
"INTERNAL_SERVER_ERROR",
"NOT_IMPLEMENTED",
"BAD_GATEWAY",
"SERVICE_UNAVAILABLE",
"GATEWAY_TIMEOUT",
"TIMEOUT",
]);
/** Every code tRPC can put on an error, used to validate an unknown value. */
export const TRPC_ERROR_CODES = new Set([
"PARSE_ERROR",
"BAD_REQUEST",
"INTERNAL_SERVER_ERROR",
"NOT_IMPLEMENTED",
"BAD_GATEWAY",
"SERVICE_UNAVAILABLE",
"GATEWAY_TIMEOUT",
"UNAUTHORIZED",
"PAYMENT_REQUIRED",
"FORBIDDEN",
"NOT_FOUND",
"METHOD_NOT_SUPPORTED",
"TIMEOUT",
"CONFLICT",
"PRECONDITION_FAILED",
"PAYLOAD_TOO_LARGE",
"UNSUPPORTED_MEDIA_TYPE",
"UNPROCESSABLE_CONTENT",
"PRECONDITION_REQUIRED",
"TOO_MANY_REQUESTS",
"CLIENT_CLOSED_REQUEST",
]);
/**
* Read the tRPC code off an unknown error.
*
* A `TRPCError` thrown on the server carries `code` directly; a
* `TRPCClientError` carries the server's code under `data`. Both shapes are
* checked, and the value is validated against {@link TRPC_ERROR_CODES} so an
* unrelated `code` property, such as a Node `ECONNREFUSED`, is not mistaken
* for one.
*/
export function getTrpcErrorCode(err: unknown): string | undefined {
if (typeof err !== "object" || err === null) {
return undefined;
}
if ("code" in err) {
const code = asTrpcErrorCode(err.code);
if (code) {
return code;
}
}
if ("data" in err && typeof err.data === "object" && err.data !== null && "code" in err.data) {
return asTrpcErrorCode(err.data.code);
}
return undefined;
}
/**
* True when the server raised this deliberately in response to the request.
*
* Such an error will not fix itself on retry, and its message describes
* something the caller can act on, so it is safe to show and safe to hydrate.
*/
export function isExpectedTrpcError(err: unknown): boolean {
const code = getTrpcErrorCode(err);
return code !== undefined && !RETRYABLE_TRPC_ERROR_CODES.has(code);
}
function asTrpcErrorCode(code: unknown): string | undefined {
return typeof code === "string" && TRPC_ERROR_CODES.has(code) ? code : undefined;
}
-1
View File
@@ -60,7 +60,6 @@
"@vercel/functions": "^3.9.6",
"@vercel/otel": "^2.1.3",
"ai": "^7.0.95",
"date-fns": "^4.4.0",
"geist": "^1.7.2",
"jotai": "^3.0.0",
"lru-cache": "^11.5.2",
+4 -54
View File
@@ -1,61 +1,14 @@
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
import superjson from "superjson";
/**
* Codes tRPC raises deliberately, where the message describes a client-side
* problem and is safe to show. Anything else (INTERNAL_SERVER_ERROR, a thrown
* driver error) may carry server internals, so it is neither retried nor
* serialized into the hydration payload.
*/
const EXPECTED_TRPC_ERROR_CODES = new Set([
"BAD_REQUEST",
"UNAUTHORIZED",
"FORBIDDEN",
"NOT_FOUND",
"METHOD_NOT_SUPPORTED",
"CONFLICT",
"PRECONDITION_FAILED",
"PAYLOAD_TOO_LARGE",
"UNPROCESSABLE_CONTENT",
"TOO_MANY_REQUESTS",
"CLIENT_CLOSED_REQUEST",
"PARSE_ERROR",
]);
import { isExpectedTrpcError } from "@/lib/trpc/errors";
function getTrpcErrorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) {
return undefined;
}
if (
"data" in error &&
typeof error.data === "object" &&
error.data !== null &&
"code" in error.data
) {
const { code } = error.data;
if (typeof code === "string") {
return code;
}
}
if ("code" in error && typeof error.code === "string") {
return error.code;
}
return undefined;
}
/** Retry transient failures; skip 4xx tRPC codes and stop after two attempts. */
/** Retry transient failures; skip deliberate tRPC errors and stop after two attempts. */
function shouldRetryQuery(failureCount: number, error: unknown): boolean {
if (failureCount >= 2) {
return false;
}
const code = getTrpcErrorCode(error);
if (code && EXPECTED_TRPC_ERROR_CODES.has(code)) {
return false;
}
return true;
return !isExpectedTrpcError(error);
}
export const makeQueryClient = () => {
@@ -76,10 +29,7 @@ export const makeQueryClient = () => {
defaultShouldDehydrateQuery(query) || query.state.status === "pending",
// Hydrate the message only for errors the server raised on purpose.
// Unexpected failures are redacted so their text stays off the wire.
shouldRedactErrors: (error) => {
const code = getTrpcErrorCode(error);
return !code || !EXPECTED_TRPC_ERROR_CODES.has(code);
},
shouldRedactErrors: (error) => !isExpectedTrpcError(error),
},
},
});
@@ -166,7 +166,7 @@ async function createNotificationRecord(params: {
}): Promise<{ notificationId: string; title: string; subject: string }> {
"use step";
const { format } = await import("date-fns");
const { formatDateLong } = await import("@domainstack/utils/date");
const { createNotification } = await import("@domainstack/db/queries/notifications");
const {
@@ -183,7 +183,7 @@ async function createNotificationRecord(params: {
const title = `SSL certificate for ${domainName} expires in ${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`;
const subject = `${daysRemaining <= 3 ? "🔒⚠️ " : "🔒 "}${title}`;
const message = `The SSL certificate for ${domainName} (issued by ${issuer}) will expire on ${format(validTo, "MMMM d, yyyy")}.`;
const message = `The SSL certificate for ${domainName} (issued by ${issuer}) will expire on ${formatDateLong(validTo)}.`;
const channels: NotificationChannel[] = [];
if (shouldSendEmail) channels.push("email");
@@ -219,7 +219,7 @@ async function sendCertificateExpiryEmail(params: {
}): Promise<{ emailId: string }> {
"use step";
const { format } = await import("date-fns");
const { formatDateLong } = await import("@domainstack/utils/date");
const { default: CertificateExpiryEmail } =
await import("@domainstack/email/templates/certificate-expiry");
const { sendEmail } = await import("@/workflows/shared/send-email");
@@ -234,7 +234,7 @@ async function sendCertificateExpiryEmail(params: {
react: CertificateExpiryEmail({
userName: userName.split(" ")[0] || "there",
domainName,
expirationDate: format(validTo, "MMMM d, yyyy"),
expirationDate: formatDateLong(validTo),
daysRemaining,
issuer,
baseUrl,
+2 -3
View File
@@ -15,12 +15,11 @@ import {
DOMAIN_TOOL_DEFS,
domainToolInputSchema,
getDomainToolErrorMessage,
getTrpcErrorCode,
isExpectedDomainToolError,
type DomainToolInput,
type DomainToolProcedure,
type DomainToolResult,
} from "@/lib/chat/domain-tools";
import { getTrpcErrorCode, isExpectedTrpcError } from "@/lib/trpc/errors";
export interface ToolContext {
ip: string | null;
@@ -54,7 +53,7 @@ async function domainLookupStep(procedure: DomainToolProcedure, domain: string,
// Domain lookups return `{ success: false }` instead of throwing.
// Throws here are tRPC validation/rate-limit errors, or unexpected bugs.
const trpcCode = getTrpcErrorCode(err);
if (isExpectedDomainToolError(err)) {
if (isExpectedTrpcError(err)) {
logger.warn({ err, domain, procedure, code: trpcCode }, "tool step failed (expected)");
return { error: getDomainToolErrorMessage(err) };
}
+4 -4
View File
@@ -166,7 +166,7 @@ async function createNotificationRecord(params: {
}): Promise<{ notificationId: string; title: string; subject: string }> {
"use step";
const { format } = await import("date-fns");
const { formatDateLong } = await import("@domainstack/utils/date");
const { createNotification } = await import("@domainstack/db/queries/notifications");
const {
@@ -183,7 +183,7 @@ async function createNotificationRecord(params: {
const title = `${domainName} expires in ${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`;
const subject = `${daysRemaining <= 7 ? "⚠️ " : ""}${title}`;
const message = `Your domain ${domainName} will expire on ${format(expirationDate, "MMMM d, yyyy")}${registrar ? ` (registered with ${registrar})` : ""}.`;
const message = `Your domain ${domainName} will expire on ${formatDateLong(expirationDate)}${registrar ? ` (registered with ${registrar})` : ""}.`;
const channels: NotificationChannel[] = [];
if (shouldSendEmail) channels.push("email");
@@ -219,7 +219,7 @@ async function sendDomainExpiryEmail(params: {
}): Promise<{ emailId: string }> {
"use step";
const { format } = await import("date-fns");
const { formatDateLong } = await import("@domainstack/utils/date");
const { default: DomainExpiryEmail } = await import("@domainstack/email/templates/domain-expiry");
const { sendEmail } = await import("@/workflows/shared/send-email");
@@ -234,7 +234,7 @@ async function sendDomainExpiryEmail(params: {
react: DomainExpiryEmail({
userName: userName.split(" ")[0] || "there",
domainName,
expirationDate: format(expirationDate, "MMMM d, yyyy"),
expirationDate: formatDateLong(expirationDate),
daysRemaining,
registrar,
baseUrl,
@@ -130,7 +130,7 @@ interface FailureActionResult {
async function determineFailureAction(domain: DomainForFailureCheck): Promise<FailureActionResult> {
"use step";
const { differenceInDays: diffInDays } = await import("date-fns");
const { calculateDaysElapsed } = await import("@domainstack/utils/expiry");
const { VERIFICATION_GRACE_PERIOD_DAYS } = await import("@domainstack/constants");
const { markVerificationFailing, revokeVerification } =
await import("@domainstack/db/queries/tracked-domains");
@@ -160,7 +160,7 @@ async function determineFailureAction(domain: DomainForFailureCheck): Promise<Fa
};
}
const daysFailing = diffInDays(now, failedAt);
const daysFailing = calculateDaysElapsed(failedAt, now);
if (daysFailing >= VERIFICATION_GRACE_PERIOD_DAYS) {
// Grace period exceeded - revoke verification
+2 -5
View File
@@ -90,12 +90,9 @@ export async function resolveProviderNamesStep(
export async function calculateDaysRemainingStep(expirationDate: Date | string): Promise<number> {
"use step";
const { differenceInDays } = await import("date-fns");
const { calculateDaysRemaining } = await import("@domainstack/utils/expiry");
const now = new Date();
const expDate = typeof expirationDate === "string" ? new Date(expirationDate) : expirationDate;
return differenceInDays(expDate, now);
return calculateDaysRemaining(expirationDate);
}
/**
@@ -127,11 +127,10 @@ async function fetchUserSubscription(userId: string): Promise<UserSubscriptionDa
async function calculateDaysRemaining(endsAt: Date): Promise<number> {
"use step";
const { differenceInDays } = await import("date-fns");
const { calculateDaysRemaining: daysUntil } = await import("@domainstack/utils/expiry");
// Getting current time inside a step ensures deterministic replay
const now = new Date();
return differenceInDays(endsAt, now);
return daysUntil(endsAt);
}
async function updateExpiryTracking(userId: string, threshold: number): Promise<void> {
@@ -162,7 +161,7 @@ async function sendSubscriptionExpiryNotification(params: {
}): Promise<boolean> {
"use step";
const { format } = await import("date-fns");
const { formatDateLong } = await import("@domainstack/utils/date");
const { default: SubscriptionCancelingEmail } =
await import("@domainstack/email/templates/subscription-canceling");
const { sendEmail } = await import("@/workflows/shared/send-email");
@@ -170,7 +169,7 @@ async function sendSubscriptionExpiryNotification(params: {
const { userName, userEmail, endsAt, daysRemaining, threshold: _ } = params;
const firstName = getFirstName(userName);
const endDate = format(endsAt, "MMMM d, yyyy");
const endDate = formatDateLong(endsAt);
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string;
// Determine urgency for subject line
+2 -2
View File
@@ -24,10 +24,10 @@
"@domainstack/db": "workspace:*",
"@domainstack/email": "workspace:*",
"@domainstack/logger": "workspace:*",
"@domainstack/utils": "workspace:*",
"@polar-sh/better-auth": "^1.8.4",
"@polar-sh/sdk": "^0.47.1",
"@vercel/functions": "^3.9.6",
"date-fns": "^4.4.0"
"@vercel/functions": "^3.9.6"
},
"devDependencies": {
"@domainstack/typescript-config": "workspace:*",
+2 -3
View File
@@ -1,11 +1,10 @@
import { format } from "date-fns";
import { getUserById } from "@domainstack/db/queries/users";
import { sendEmail } from "@domainstack/email";
import ProUpgradeSuccessEmail from "@domainstack/email/templates/pro-upgrade-success";
import SubscriptionCancelingEmail from "@domainstack/email/templates/subscription-canceling";
import SubscriptionExpiredEmail from "@domainstack/email/templates/subscription-expired";
import { createLogger } from "@domainstack/logger";
import { formatDateLong } from "@domainstack/utils/date";
const logger = createLogger({ source: "polar/emails" });
@@ -53,7 +52,7 @@ export async function sendSubscriptionCancelingEmail(
subject: "Your Pro subscription is ending",
react: SubscriptionCancelingEmail({
userName: user.name || "there",
endDate: format(periodEnd, "MMMM d, yyyy"),
endDate: formatDateLong(periodEnd),
baseUrl,
}),
},
+6
View File
@@ -56,6 +56,12 @@ describe("isExpectedDnsError", () => {
expect(isExpectedDnsError(err)).toBe(false);
});
it("does not treat a plain DNS timeout message as permanent", () => {
// The message mentions DNS but describes a transient failure, so a
// substring match on "dns" would wrongly mark it permanent.
expect(isExpectedDnsError(new Error("DNS query timed out"))).toBe(false);
});
it("detects getaddrinfo errors by message", () => {
expect(isExpectedDnsError(new Error("getaddrinfo ENOTFOUND example.com"))).toBe(true);
});
+2 -23
View File
@@ -8,15 +8,11 @@
import { ensureDomainRecord } from "@domainstack/db/queries/domains";
import { upsertFavicon } from "@domainstack/db/queries/favicons";
import { optimizeImage, storeImage } from "@domainstack/image";
import {
isExpectedDnsError,
safeFetch,
SafeFetchError,
type SafeFetchErrorCode,
} from "@domainstack/safe-fetch";
import { safeFetch } from "@domainstack/safe-fetch";
import type { FaviconResponse } from "@domainstack/types";
import { ttlForFavicon } from "../ttl";
import { isDefinitiveNotFoundError } from "./fetch-errors";
// ============================================================================
// Types
@@ -167,23 +163,6 @@ async function fetchIconFromSources(domain: string): Promise<IconFetchResult> {
return { success: false, allNotFound };
}
const DEFINITIVE_CODES = new Set<SafeFetchErrorCode>([
"host_blocked",
"host_not_allowed",
"private_ip",
"protocol_not_allowed",
"invalid_url",
]);
function isDefinitiveNotFoundError(err: unknown): boolean {
if (!(err instanceof SafeFetchError)) return false;
// safe-fetch also raises dns_error for lookup timeouts, which are transient.
if (err.code === "dns_error") return isExpectedDnsError(err);
return DEFINITIVE_CODES.has(err.code);
}
// ============================================================================
// Internal: Process and Store
// ============================================================================
@@ -0,0 +1,33 @@
import {
isExpectedDnsError,
SafeFetchError,
type SafeFetchErrorCode,
} from "@domainstack/safe-fetch";
/**
* Failures that mean "this URL will never serve us an asset", as opposed to
* "the attempt failed this time". Retrying any of these produces the same
* result, so callers record a permanent not-found instead of scheduling work.
*/
const DEFINITIVE_CODES = new Set<SafeFetchErrorCode>([
"host_blocked",
"host_not_allowed",
"private_ip",
"protocol_not_allowed",
"invalid_url",
]);
/**
* True when an asset fetch failed for a reason that will not change on retry.
*
* Shared by the favicon and provider-logo services, which make the same
* permanent-versus-transient call and previously each kept their own copy.
*/
export function isDefinitiveNotFoundError(err: unknown): boolean {
if (!(err instanceof SafeFetchError)) return false;
// safe-fetch also raises dns_error for lookup timeouts, which are transient.
if (err.code === "dns_error") return isExpectedDnsError(err);
return DEFINITIVE_CODES.has(err.code);
}
+2 -23
View File
@@ -6,15 +6,11 @@
import { upsertProviderLogo } from "@domainstack/db/queries/provider-logos";
import { optimizeImage, storeImage } from "@domainstack/image";
import {
isExpectedDnsError,
safeFetch,
SafeFetchError,
type SafeFetchErrorCode,
} from "@domainstack/safe-fetch";
import { safeFetch } from "@domainstack/safe-fetch";
import type { ProviderLogoResponse } from "@domainstack/types";
import { ttlForProviderIcon } from "../ttl";
import { isDefinitiveNotFoundError } from "./fetch-errors";
// ============================================================================
// Types
@@ -185,23 +181,6 @@ async function fetchIconFromSources(domain: string): Promise<IconFetchResult> {
return { success: false, allNotFound };
}
const DEFINITIVE_CODES = new Set<SafeFetchErrorCode>([
"host_blocked",
"host_not_allowed",
"private_ip",
"protocol_not_allowed",
"invalid_url",
]);
function isDefinitiveNotFoundError(err: unknown): boolean {
if (!(err instanceof SafeFetchError)) return false;
// safe-fetch also raises dns_error for lookup timeouts, which are transient.
if (err.code === "dns_error") return isExpectedDnsError(err);
return DEFINITIVE_CODES.has(err.code);
}
// ============================================================================
// Internal: Process and Store
// ============================================================================
+3 -4
View File
@@ -12,7 +12,7 @@ import type { TLSSocket } from "node:tls";
import {
createPinnedLookup,
isExpectedDnsError as isSafeFetchDnsError,
isExpectedDnsError,
resolvePublicHost,
SafeFetchError,
} from "@domainstack/safe-fetch";
@@ -21,7 +21,6 @@ import type { TlsFetchOptions, TlsFetchResult } from "./types";
import {
InvalidCertificateDateError,
isEmptyPeerCertificate,
isExpectedDnsError,
isExpectedTlsError,
readTlsAuthorization,
walkCertificateChain,
@@ -111,7 +110,7 @@ export async function fetchCertificateChain(
return { success: false, error: "timeout" };
}
if (isExpectedDnsError(err) || isSafeFetchDnsError(err)) {
if (isExpectedDnsError(err)) {
return { success: false, error: "dns_error" };
}
@@ -164,7 +163,7 @@ function mapResolutionError(err: unknown): TlsFetchResult {
}
}
if (isExpectedDnsError(err) || isSafeFetchDnsError(err)) {
if (isExpectedDnsError(err)) {
return { success: false, error: "dns_error" };
}
-31
View File
@@ -14,7 +14,6 @@ import {
} from "./fixtures";
import {
isEmptyPeerCertificate,
isExpectedDnsError,
isExpectedTlsError,
parseAltNames,
parseCertificateDate,
@@ -104,36 +103,6 @@ describe("isExpectedTlsError", () => {
});
});
describe("isExpectedDnsError", () => {
it("returns false for non-Error values", () => {
expect(isExpectedDnsError("error")).toBe(false);
expect(isExpectedDnsError(null)).toBe(false);
});
it("detects ENOTFOUND errors", () => {
const err = new Error("DNS error");
(err as unknown as { code: string }).code = "ENOTFOUND";
expect(isExpectedDnsError(err)).toBe(true);
});
it("treats EAI_AGAIN as retryable, not permanent", () => {
const err = new Error("DNS error");
(err as unknown as { code: string }).code = "EAI_AGAIN";
expect(isExpectedDnsError(err)).toBe(false);
});
it("detects ENODATA when A/AAAA records are missing", () => {
const err = new Error("queryA ENODATA example.com");
(err as unknown as { code: string }).code = "ENODATA";
expect(isExpectedDnsError(err)).toBe(true);
});
it("detects getaddrinfo errors by message", () => {
const err = new Error("getaddrinfo ENOTFOUND example.com");
expect(isExpectedDnsError(err)).toBe(true);
});
});
describe("parseCertificateDate", () => {
it("returns null for invalid dates", () => {
expect(parseCertificateDate("not-a-date")).toBeNull();
-29
View File
@@ -74,35 +74,6 @@ export function isExpectedTlsError(err: unknown): boolean {
);
}
/**
* Check if an error is a permanent DNS failure.
*
* `EAI_AGAIN` is excluded: getaddrinfo returns it for a *temporary* resolver
* failure, so it stays retryable.
*/
export function isExpectedDnsError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const anyErr = err as unknown as {
cause?: { code?: string; message?: string };
code?: string;
message?: string;
};
const code = anyErr?.cause?.code || anyErr?.code;
const message = (anyErr?.cause?.message || anyErr?.message || "").toLowerCase();
if (code === "EAI_AGAIN" || message.includes("eai_again")) {
return false;
}
return (
code === "ENOTFOUND" ||
code === "ENODATA" ||
code === "ENOENT" ||
message.includes("getaddrinfo") ||
message.includes("dns")
);
}
/**
* Thrown when a peer certificate contains a date that cannot be parsed.
*/
@@ -26,6 +26,7 @@ import type {
} from "@domainstack/types";
import { normalizeCertificateHex } from "../certificate-hex";
import { normalizeDnsHost } from "../providers/detection";
import { statusesAreEqual } from "./status";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -57,12 +58,11 @@ export function detectRegistrationChange(
// Check nameserver changes (order-independent, case-insensitive per RFC 4343).
// The root label is stripped so "ns1.example.com." and "ns1.example.com" are
// the same host and do not raise a spurious change notification.
const normalizeNsHost = (host: string) => host.trim().toLowerCase().replace(/\.$/, "");
const prevNsHosts = [...snapshotNameservers]
.map((ns) => normalizeNsHost(ns.host))
.map((ns) => normalizeDnsHost(ns.host))
.sort((a, b) => a.localeCompare(b));
const currNsHosts = [...currentNameservers]
.map((ns) => normalizeNsHost(ns.host))
.map((ns) => normalizeDnsHost(ns.host))
.sort((a, b) => a.localeCompare(b));
const nameserversChanged =
prevNsHosts.length !== currNsHosts.length ||
+90 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { toDateTimeAttr, toDurationAttr } from "./date";
import { formatDateLong, formatRelativeTime, toDateTimeAttr, toDurationAttr } from "./date";
describe("toDateTimeAttr", () => {
it("returns a UTC ISO 8601 string for Date, ISO string, and epoch ms", () => {
@@ -28,3 +28,92 @@ describe("toDurationAttr", () => {
expect(toDurationAttr(Number.POSITIVE_INFINITY)).toBeUndefined();
});
});
describe("formatDateLong", () => {
it("spells the month out", () => {
expect(formatDateLong("2025-10-02T14:30:05.000Z")).toBe("October 2, 2025");
});
it("uses the UTC calendar day regardless of the runtime timezone", () => {
// The reason this exists: date-fns `format` renders in local time, so this
// instant reads as the previous day anywhere west of UTC. An expiry email
// must name the same day the dashboard does.
const justAfterUtcMidnight = "2026-03-01T02:00:00.000Z";
expect(formatDateLong(justAfterUtcMidnight)).toBe("March 1, 2026");
});
it("falls back to the raw string for an unparseable date", () => {
expect(formatDateLong("not-a-date")).toBe("not-a-date");
});
});
const now = new Date("2025-06-15T12:00:00.000Z");
const SECOND = 1_000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** `now` shifted by `ms`, for readable cases below. */
function at(ms: number): Date {
return new Date(now.getTime() + ms);
}
describe("formatRelativeTime", () => {
it("adds a suffix in both directions", () => {
expect(formatRelativeTime(at(5 * DAY), now)).toBe("in 5 days");
expect(formatRelativeTime(at(-5 * DAY), now)).toBe("5 days ago");
});
it("accepts a Date, an ISO string, and epoch milliseconds alike", () => {
const target = at(5 * DAY);
expect(formatRelativeTime(target.toISOString(), now)).toBe("in 5 days");
expect(formatRelativeTime(target.getTime(), now)).toBe("in 5 days");
});
it("picks the largest unit that fits", () => {
expect(formatRelativeTime(at(30 * SECOND), now)).toBe("in 30 seconds");
expect(formatRelativeTime(at(90 * SECOND), now)).toBe("in 2 minutes");
expect(formatRelativeTime(at(59 * MINUTE), now)).toBe("in 59 minutes");
expect(formatRelativeTime(at(90 * MINUTE), now)).toBe("in 2 hours");
expect(formatRelativeTime(at(23 * HOUR), now)).toBe("in 23 hours");
expect(formatRelativeTime(at(29 * DAY), now)).toBe("in 29 days");
expect(formatRelativeTime(at(31 * DAY), now)).toBe("in 1 month");
expect(formatRelativeTime(at(200 * DAY), now)).toBe("in 7 months");
expect(formatRelativeTime(at(400 * DAY), now)).toBe("in 1 year");
});
it("promotes a full twelve months to a year", () => {
expect(formatRelativeTime(at(360 * DAY), now)).toBe("in 1 year");
expect(formatRelativeTime(at(-360 * DAY), now)).toBe("1 year ago");
});
it("rounds a half unit away from zero in both directions", () => {
// Rounding the signed value would give "48 minutes ago" here, because
// Math.round breaks the -48.5 tie upward.
expect(formatRelativeTime(at(-48.5 * MINUTE), now)).toBe("49 minutes ago");
expect(formatRelativeTime(at(48.5 * MINUTE), now)).toBe("in 49 minutes");
});
it("treats an identical instant as just past", () => {
expect(formatRelativeTime(now, now)).toBe("0 seconds ago");
});
it("preserves direction when fractional seconds round to zero", () => {
expect(formatRelativeTime(at(499), now)).toBe("in 0 seconds");
expect(formatRelativeTime(at(-499), now)).toBe("0 seconds ago");
});
it("counts days on the wall clock across a DST change", () => {
// US clocks go forward on 2025-03-09, so this span is 10 days on the
// calendar but one hour short of 10 * 24h in absolute time.
const before = new Date("2025-03-05T12:00:00-05:00");
const after = new Date("2025-03-15T12:00:00-04:00");
expect(formatRelativeTime(after, before)).toBe("in 10 days");
expect(formatRelativeTime(before, after)).toBe("10 days ago");
});
it("returns undefined rather than throwing on an invalid date", () => {
expect(formatRelativeTime("not-a-date", now)).toBeUndefined();
expect(formatRelativeTime(now, new Date(Number.NaN))).toBeUndefined();
});
});
+126
View File
@@ -30,6 +30,34 @@ export function formatDate(value: string | Date): string {
}
}
/**
* Formats a date in UTC with the month spelled out, e.g. "October 2, 2025".
*
* The long form used in notification emails. It goes through `Intl` in UTC for
* the same reason {@link formatDate} does: date-fns `format` renders in the
* runtime's local timezone, so an expiry just after midnight UTC would be
* announced as the previous day in an email while the dashboard showed the
* correct one.
*
* @param value - ISO 8601 date string or Date
* @returns Formatted date string (e.g., "October 2, 2025")
*/
export function formatDateLong(value: string | Date): string {
try {
const d = toDate(value);
if (Number.isNaN(d.getTime())) return fallbackDateLabel(value);
return new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(d);
} catch {
return fallbackDateLabel(value);
}
}
/**
* Formats a date as ISO-like datetime in UTC using native Intl.DateTimeFormat API.
* @param value - ISO 8601 date string or Date
@@ -88,3 +116,101 @@ export function toDurationAttr(seconds: number): string | undefined {
if (!Number.isFinite(seconds) || seconds < 0) return undefined;
return `PT${seconds}S`;
}
/**
* Relative timestamps, e.g. "in 5 days" or "3 months ago".
*
* Built on `Intl.RelativeTimeFormat` so the wording comes from the platform's
* own locale data rather than a bundled table. This replaced date-fns
* `formatDistanceStrict` and reproduces its output exactly; the two details
* that make it exact are called out at their branches below.
*
* `Temporal` is deliberately not used here. It has no relative formatting of
* its own, so it would not replace this, and Safari has not shipped it.
*/
/** Cached: constructing an Intl formatter is far more costly than using one. */
const relativeFormatter = new Intl.RelativeTimeFormat("en", { numeric: "always" });
const MS_PER_MINUTE = 60_000;
const MINUTES_IN_HOUR = 60;
const MINUTES_IN_DAY = 1_440;
const MINUTES_IN_MONTH = 43_200; // 30 days
const MINUTES_IN_YEAR = 525_600; // 365 days
/**
* The offset between a date's wall-clock reading and the same reading in UTC.
* Used to cancel out a DST shift so a span is counted in calendar terms.
*/
function timezoneOffsetMs(date: Date): number {
const asUtc = new Date(
Date.UTC(
date.getFullYear(),
date.getMonth(),
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
date.getMilliseconds(),
),
);
// Years 0-99 would otherwise be read as 19xx.
asUtc.setUTCFullYear(date.getFullYear());
return date.getTime() - asUtc.getTime();
}
/**
* Formats the distance between two instants as a single unit with a suffix.
*
* @param value - The instant being described
* @param now - The instant to describe it relative to
* @returns Relative label, or undefined when either date is invalid
*/
export function formatRelativeTime(value: string | Date | number, now: Date): string | undefined {
const target = toDate(value);
const elapsedMs = target.getTime() - now.getTime();
if (Number.isNaN(elapsedMs)) return undefined;
const minutes = elapsedMs / MS_PER_MINUTE;
// Days and above are counted on the wall clock, so a span crossing a clock
// change is not one hour longer or shorter than the calendar says.
const calendarMinutes =
(elapsedMs - (timezoneOffsetMs(target) - timezoneOffsetMs(now))) / MS_PER_MINUTE;
const absMinutes = Math.abs(minutes);
const absCalendarMinutes = Math.abs(calendarMinutes);
// Round the magnitude and reapply the direction. Rounding the signed value
// instead would turn -48.5 minutes into "48 minutes ago", because
// Math.round breaks a tie upward and -48 is the larger number.
const direction = elapsedMs < 0 ? -1 : 1;
const round = (amount: number) => direction * Math.round(Math.abs(amount)) || 0;
if (absMinutes < 1) {
// An identical instant reads as just past rather than just future.
const seconds = round(elapsedMs / 1_000);
return relativeFormatter.format(seconds === 0 && elapsedMs <= 0 ? -0 : seconds, "second");
}
if (absMinutes < MINUTES_IN_HOUR) {
return relativeFormatter.format(round(minutes), "minute");
}
if (absMinutes < MINUTES_IN_DAY) {
return relativeFormatter.format(round(minutes / MINUTES_IN_HOUR), "hour");
}
if (absCalendarMinutes < MINUTES_IN_MONTH) {
return relativeFormatter.format(round(calendarMinutes / MINUTES_IN_DAY), "day");
}
if (absCalendarMinutes < MINUTES_IN_YEAR) {
const months = round(calendarMinutes / MINUTES_IN_MONTH);
// Rounding up to a full twelve reads better as a year.
return Math.abs(months) === 12
? relativeFormatter.format(Math.sign(months), "year")
: relativeFormatter.format(months, "month");
}
return relativeFormatter.format(round(calendarMinutes / MINUTES_IN_YEAR), "year");
}
+34 -1
View File
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
import { CERTIFICATE_EXPIRY_THRESHOLDS, DOMAIN_EXPIRY_THRESHOLDS } from "@domainstack/constants";
import { calculateDaysRemaining, getThresholdNotificationType } from "./expiry";
import {
calculateDaysElapsed,
calculateDaysRemaining,
getThresholdNotificationType,
} from "./expiry";
describe("getThresholdNotificationType", () => {
it("returns smallest matching threshold for domain expiry", () => {
@@ -118,3 +122,32 @@ describe("getThresholdNotificationType with an unusable date", () => {
expect(getThresholdNotificationType(days, [30, 14, 7, 1], "domain_expiry")).toBeNull();
});
});
describe("calculateDaysElapsed", () => {
const now = new Date("2024-06-15T12:00:00Z");
it("counts whole days since a past date", () => {
expect(calculateDaysElapsed(new Date("2024-06-10T12:00:00Z"), now)).toBe(5);
});
it("ignores a partial day rather than rounding it up", () => {
expect(calculateDaysElapsed(new Date("2024-06-10T00:00:00Z"), now)).toBe(5);
});
it("returns 0 for the same instant", () => {
expect(calculateDaysElapsed(now, now)).toBe(0);
});
it("is negative for a future date", () => {
expect(calculateDaysElapsed(new Date("2024-06-20T12:00:00Z"), now)).toBe(-5);
});
it("mirrors calculateDaysRemaining with the arguments swapped", () => {
const other = new Date("2024-07-01T00:00:00Z");
expect(calculateDaysElapsed(other, now)).toBe(calculateDaysRemaining(now, other));
});
it("accepts an ISO string", () => {
expect(calculateDaysElapsed("2024-06-10T12:00:00Z", now)).toBe(5);
});
});
+16
View File
@@ -94,3 +94,19 @@ export function calculateDaysRemaining(
const diffMs = expDate.getTime() - now.getTime();
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}
/**
* Calculate the number of whole days that have passed since a given date.
*
* The mirror of {@link calculateDaysRemaining}, for grace periods and other
* "how long has this been true" counts. Both live here so a server-side
* decision and the badge describing it can never round differently.
*
* @param since - The date to count from
* @param now - The current date (defaults to new Date())
* @returns Whole days elapsed (negative if `since` is in the future, `NaN` if
* the date cannot be parsed)
*/
export function calculateDaysElapsed(since: Date | string, now: Date = new Date()): number {
return calculateDaysRemaining(now, typeof since === "string" ? new Date(since) : since);
}
+58 -49
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { PROVIDER_CATEGORIES } from "@domainstack/constants";
import type { ProviderCategory } from "@domainstack/types";
import type { Rule } from "./rules";
@@ -37,58 +38,66 @@ export interface Provider extends ProviderEntry {
* }
* ```
*/
const ProviderCatalogSchema = z
.object({
ca: z.array(ProviderEntrySchema).default([]),
dns: z.array(ProviderEntrySchema).default([]),
email: z.array(ProviderEntrySchema).default([]),
hosting: z.array(ProviderEntrySchema).default([]),
registrar: z.array(ProviderEntrySchema).default([]),
})
.superRefine((catalog, ctx) => {
// Validate all regex patterns at parse time
const validateRegexInRule = (
rule: Rule,
category: string,
providerName: string,
path: string[],
): void => {
if ("all" in rule) {
for (let i = 0; i < rule.all.length; i++) {
validateRegexInRule(rule.all[i], category, providerName, [...path, "all", String(i)]);
}
} else if ("any" in rule) {
for (let i = 0; i < rule.any.length; i++) {
validateRegexInRule(rule.any[i], category, providerName, [...path, "any", String(i)]);
}
} else if ("not" in rule) {
validateRegexInRule(rule.not, category, providerName, [...path, "not"]);
} else if (rule.kind === "mxRegex" || rule.kind === "nsRegex") {
try {
new RegExp(rule.pattern, rule.flags);
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid regex pattern in ${category}.${providerName}: ${rule.pattern} - ${e instanceof Error ? e.message : "unknown error"}`,
path: [...path, "pattern"],
});
}
}
};
// Typed as a Record over ProviderCategory so a category added to
// PROVIDER_CATEGORIES fails to compile here until the catalog gains a key for
// it, rather than silently parsing to a catalog that is missing the section.
const catalogShape: Record<
ProviderCategory,
z.ZodDefault<z.ZodArray<typeof ProviderEntrySchema>>
> = {
ca: z.array(ProviderEntrySchema).default([]),
dns: z.array(ProviderEntrySchema).default([]),
email: z.array(ProviderEntrySchema).default([]),
hosting: z.array(ProviderEntrySchema).default([]),
registrar: z.array(ProviderEntrySchema).default([]),
};
const categories: ProviderCategory[] = ["ca", "dns", "email", "hosting", "registrar"];
for (const category of categories) {
const providers = catalog[category];
for (let index = 0; index < providers.length; index++) {
const provider = providers[index];
validateRegexInRule(provider.rule, category, provider.name, [
category,
String(index),
"rule",
]);
const ProviderCatalogSchema = z.object(catalogShape).superRefine((catalog, ctx) => {
// Validate all regex patterns at parse time
const validateRegexInRule = (
rule: Rule,
category: string,
providerName: string,
path: string[],
): void => {
if ("all" in rule) {
for (let i = 0; i < rule.all.length; i++) {
validateRegexInRule(rule.all[i], category, providerName, [...path, "all", String(i)]);
}
} else if ("any" in rule) {
for (let i = 0; i < rule.any.length; i++) {
validateRegexInRule(rule.any[i], category, providerName, [...path, "any", String(i)]);
}
} else if ("not" in rule) {
validateRegexInRule(rule.not, category, providerName, [...path, "not"]);
} else if (rule.kind === "mxRegex" || rule.kind === "nsRegex") {
try {
new RegExp(rule.pattern, rule.flags);
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid regex pattern in ${category}.${providerName}: ${rule.pattern} - ${e instanceof Error ? e.message : "unknown error"}`,
path: [...path, "pattern"],
});
}
}
});
};
// Iterate the shared list: a hardcoded copy annotated `ProviderCategory[]`
// rejects an invalid member but says nothing about a missing one, so a new
// category would silently skip regex validation.
for (const category of PROVIDER_CATEGORIES) {
const providers = catalog[category];
for (let index = 0; index < providers.length; index++) {
const provider = providers[index];
validateRegexInRule(provider.rule, category, provider.name, [
category,
String(index),
"rule",
]);
}
}
});
export type ProviderCatalog = z.infer<typeof ProviderCatalogSchema>;
+3 -6
View File
@@ -189,9 +189,6 @@ importers:
ai:
specifier: ^7.0.95
version: 7.0.95(zod@4.5.4)
date-fns:
specifier: ^4.4.0
version: 4.4.0
geist:
specifier: ^1.7.2
version: 1.7.2(next@16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))
@@ -582,6 +579,9 @@ importers:
'@domainstack/logger':
specifier: workspace:*
version: link:../logger
'@domainstack/utils':
specifier: workspace:*
version: link:../utils
'@polar-sh/better-auth':
specifier: ^1.8.4
version: 1.8.4(@polar-sh/sdk@0.47.1)(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stripe/stripe-js@7.9.0)(better-auth@1.7.3(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(@upstash/redis@1.38.4)(kysely@0.29.5)(pg@8.23.0))(next@16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0))(react@19.2.8)(zod@4.5.4)
@@ -591,9 +591,6 @@ importers:
'@vercel/functions':
specifier: ^3.9.6
version: 3.9.6(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.21.3(bufferutil@4.1.0))
date-fns:
specifier: ^4.4.0
version: 4.4.0
next:
specifier: 16.3.4
version: 16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)