1
mirror of https://github.com/jakejarvis/domainstack.io.git synced 2025-12-02 19:33:48 -05:00

refactor: remove cron job and related pricing refresh logic, transitioning to Next.js caching for improved performance

This commit is contained in:
2025-11-18 14:05:28 -05:00
parent 076a8c4eea
commit 60672383c2
4 changed files with 29 additions and 504 deletions

View File

@@ -1,58 +0,0 @@
import { NextResponse } from "next/server";
import { refreshAllProviderPricing } from "@/server/services/pricing";
export async function GET(request: Request) {
// Verify Vercel cron secret
const authHeader = request.headers.get("authorization");
const expectedAuth = process.env.CRON_SECRET
? `Bearer ${process.env.CRON_SECRET}`
: null;
if (!expectedAuth) {
console.error("[pricing-refresh] cron misconfigured: CRON_SECRET missing");
return NextResponse.json(
{ error: "CRON_SECRET not configured" },
{ status: 500 },
);
}
if (authHeader !== expectedAuth) {
console.warn("[pricing-refresh] cron unauthorized");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const startedAt = Date.now();
const result = await refreshAllProviderPricing();
const durationMs = Date.now() - startedAt;
if (result.failed.length > 0) {
console.warn(
`[pricing-refresh] completed with errors: refreshed=${result.refreshed.length} failed=${result.failed.length} ${durationMs}ms`,
);
} else {
console.info(
`[pricing-refresh] completed: refreshed=${result.refreshed.length} ${durationMs}ms`,
);
}
return NextResponse.json({
success: true,
refreshed: result.refreshed,
failed: result.failed,
durationMs,
});
} catch (err) {
console.error(
"[pricing-refresh] cron failed",
err instanceof Error ? err : new Error(String(err)),
);
return NextResponse.json(
{
error: "Internal error",
message: err instanceof Error ? err.message : "unknown",
},
{ status: 500 },
);
}
}

View File

@@ -1,343 +0,0 @@
/* @vitest-environment node */
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
let getPricingForTld: typeof import("./pricing").getPricingForTld;
let refreshAllProviderPricing: typeof import("./pricing").refreshAllProviderPricing;
// Mock registrar pricing responses (standardized format)
const mockPorkbunResponse = {
com: { registration: "9.99", renewal: "10.99" },
dev: { registration: "12.00", renewal: "12.00" },
org: { registration: "11.50" },
};
const mockCloudflareResponse = {
com: { registration: "10.44" },
dev: { registration: "13.50" },
net: { registration: "12.88" },
};
describe("pricing service", () => {
beforeAll(async () => {
const { makeInMemoryRedis } = await import("@/lib/redis-mock");
const impl = makeInMemoryRedis();
vi.doMock("@/lib/redis", () => impl);
const module = await import("./pricing");
getPricingForTld = module.getPricingForTld;
refreshAllProviderPricing = module.refreshAllProviderPricing;
});
afterEach(async () => {
vi.restoreAllMocks();
const { resetInMemoryRedis } = await import("@/lib/redis-mock");
resetInMemoryRedis();
});
describe("getPricingForTld", () => {
it("returns empty providers for single-label hosts", async () => {
const res = await getPricingForTld("localhost");
expect(res.tld).toBeNull();
expect(res.providers).toEqual([]);
});
it("returns empty providers for empty input", async () => {
const res = await getPricingForTld("");
expect(res.tld).toBeNull();
expect(res.providers).toEqual([]);
});
it("fetches from all providers and caches responses on miss", async () => {
// Mock both Porkbun and Cloudflare APIs (raw formats)
const fetchSpy = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: "SUCCESS",
pricing: mockPorkbunResponse,
}),
} as unknown as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => mockCloudflareResponse,
} as unknown as Response);
const res = await getPricingForTld("example.com");
expect(res.tld).toBe("com");
expect(res.providers).toHaveLength(2);
expect(res.providers).toContainEqual({
provider: "porkbun",
price: "9.99",
});
expect(res.providers).toContainEqual({
provider: "cloudflare",
price: "10.44",
});
// Verify both caches were set
const { redis } = await import("@/lib/redis");
expect(await redis.exists("pricing:porkbun")).toBe(1);
expect(await redis.exists("pricing:cloudflare")).toBe(1);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it("uses cached provider data without fetching again", async () => {
// Seed cache with both provider data
const { redis } = await import("@/lib/redis");
await redis.set("pricing:porkbun", mockPorkbunResponse);
await redis.set("pricing:cloudflare", mockCloudflareResponse);
const fetchSpy = vi.spyOn(global, "fetch");
const res = await getPricingForTld("something.dev");
expect(res.tld).toBe("dev");
expect(res.providers).toHaveLength(2);
expect(res.providers).toContainEqual({
provider: "porkbun",
price: "12.00",
});
expect(res.providers).toContainEqual({
provider: "cloudflare",
price: "13.50",
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it("handles upstream API error gracefully", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: false,
status: 500,
} as unknown as Response)
.mockResolvedValueOnce({
ok: false,
status: 500,
} as unknown as Response);
const res = await getPricingForTld("example.com");
expect(res.tld).toBe("com");
expect(res.providers).toEqual([]);
});
it("handles network fetch error gracefully", async () => {
vi.spyOn(global, "fetch")
.mockRejectedValueOnce(new Error("Network timeout"))
.mockRejectedValueOnce(new Error("Network timeout"));
const res = await getPricingForTld("example.org");
expect(res.tld).toBe("org");
expect(res.providers).toEqual([]);
});
it("returns empty providers when TLD is not found in any provider response", async () => {
// Seed cache with data that doesn't include .xyz
const { redis } = await import("@/lib/redis");
await redis.set("pricing:porkbun", mockPorkbunResponse);
await redis.set("pricing:cloudflare", mockCloudflareResponse);
const res = await getPricingForTld("example.xyz");
expect(res.tld).toBe("xyz");
expect(res.providers).toEqual([]);
});
it("normalizes domain input (case, whitespace)", async () => {
const { redis } = await import("@/lib/redis");
await redis.set("pricing:porkbun", mockPorkbunResponse);
await redis.set("pricing:cloudflare", mockCloudflareResponse);
const res = await getPricingForTld(" EXAMPLE.COM ");
expect(res.tld).toBe("com");
expect(res.providers).toHaveLength(2);
expect(res.providers).toContainEqual({
provider: "porkbun",
price: "9.99",
});
expect(res.providers).toContainEqual({
provider: "cloudflare",
price: "10.44",
});
});
it("handles subdomains correctly by extracting TLD", async () => {
const { redis } = await import("@/lib/redis");
await redis.set("pricing:porkbun", mockPorkbunResponse);
await redis.set("pricing:cloudflare", mockCloudflareResponse);
const res = await getPricingForTld("www.example.dev");
expect(res.tld).toBe("dev");
expect(res.providers).toHaveLength(2);
expect(res.providers).toContainEqual({
provider: "porkbun",
price: "12.00",
});
expect(res.providers).toContainEqual({
provider: "cloudflare",
price: "13.50",
});
});
});
describe("refreshAllProviderPricing", () => {
it("successfully refreshes all providers and populates cache", async () => {
// Mock both Porkbun and Cloudflare APIs (raw formats)
const fetchSpy = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: "SUCCESS",
pricing: mockPorkbunResponse,
}),
} as unknown as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => mockCloudflareResponse,
} as unknown as Response);
const result = await refreshAllProviderPricing();
expect(result.refreshed).toEqual(["porkbun", "cloudflare"]);
expect(result.failed).toEqual([]);
expect(fetchSpy).toHaveBeenCalledTimes(2);
// Verify both caches were populated
const { redis } = await import("@/lib/redis");
const porkbunCached = await redis.get("pricing:porkbun");
expect(porkbunCached).toEqual(mockPorkbunResponse);
const cloudflareCached = await redis.get("pricing:cloudflare");
expect(cloudflareCached).toEqual(mockCloudflareResponse);
});
it("handles API errors and reports failed providers", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: false,
status: 503,
} as unknown as Response)
.mockResolvedValueOnce({
ok: false,
status: 503,
} as unknown as Response);
const result = await refreshAllProviderPricing();
expect(result.refreshed).toEqual([]);
expect(result.failed).toEqual(["porkbun", "cloudflare"]);
// Verify caches were not populated
const { redis } = await import("@/lib/redis");
expect(await redis.exists("pricing:porkbun")).toBe(0);
expect(await redis.exists("pricing:cloudflare")).toBe(0);
});
it("handles network errors and reports failed providers", async () => {
vi.spyOn(global, "fetch")
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
.mockRejectedValueOnce(new Error("ECONNREFUSED"));
const result = await refreshAllProviderPricing();
expect(result.refreshed).toEqual([]);
expect(result.failed).toEqual(["porkbun", "cloudflare"]);
});
it("refreshes even when cache already exists", async () => {
// Pre-populate cache with old data
const { redis } = await import("@/lib/redis");
const oldData = { com: { registration: "8.88" } };
await redis.set("pricing:porkbun", oldData);
await redis.set("pricing:cloudflare", oldData);
// Mock APIs with new data (raw formats)
const fetchSpy = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: "SUCCESS",
pricing: mockPorkbunResponse,
}),
} as unknown as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => mockCloudflareResponse,
} as unknown as Response);
const result = await refreshAllProviderPricing();
expect(result.refreshed).toEqual(["porkbun", "cloudflare"]);
expect(result.failed).toEqual([]);
expect(fetchSpy).toHaveBeenCalledTimes(2);
// Verify caches were updated with new data
const porkbunCached = await redis.get("pricing:porkbun");
expect(porkbunCached).toEqual(mockPorkbunResponse);
expect(porkbunCached).not.toEqual(oldData);
});
});
describe("getPricingForTld - negative caching", () => {
it("returns pricing successfully from API and caches it", async () => {
const { redis } = await import("@/lib/redis");
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: "SUCCESS",
pricing: mockPorkbunResponse,
}),
} as unknown as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => mockCloudflareResponse,
} as unknown as Response);
await getPricingForTld("example.com");
// Verify pricing data was cached
const porkbunCached = await redis.get("pricing:porkbun");
expect(porkbunCached).toEqual(mockPorkbunResponse);
const cloudflareCached = await redis.get("pricing:cloudflare");
expect(cloudflareCached).toEqual(mockCloudflareResponse);
});
it("caches sentinel value with short TTL when fetch fails", async () => {
const { redis } = await import("@/lib/redis");
vi.spyOn(global, "fetch")
.mockRejectedValueOnce(new Error("Network error"))
.mockRejectedValueOnce(new Error("Network error"));
await getPricingForTld("example.com");
// Verify negative cache (null) was set to prevent repeated failed fetches
const porkbunCached = await redis.get("pricing:porkbun");
expect(porkbunCached).toBe("error");
const cloudflareCached = await redis.get("pricing:cloudflare");
expect(cloudflareCached).toBe("error");
});
it("sets negative cache with short TTL on fetch error", async () => {
const { redis } = await import("@/lib/redis");
vi.spyOn(global, "fetch")
.mockRejectedValueOnce(new Error("API down"))
.mockRejectedValueOnce(new Error("API down"));
await getPricingForTld("example.com");
// Verify negative cache was set for both providers
const porkbunCached = await redis.get("pricing:porkbun");
expect(porkbunCached).toBe("error");
const cloudflareCached = await redis.get("pricing:cloudflare");
expect(cloudflareCached).toBe("error");
// Verify TTL is short (should be 60 seconds for negative cache)
const porkbunTtl = await redis.ttl("pricing:porkbun");
expect(porkbunTtl).toBeGreaterThan(0);
expect(porkbunTtl).toBeLessThanOrEqual(60);
});
});
});

View File

@@ -1,8 +1,23 @@
import { after } from "next/server";
"use cache";
import { getDomainTld } from "rdapper";
import { ns, redis } from "@/lib/redis";
import type { Pricing } from "@/lib/schemas";
/**
* Domain registration pricing service.
*
* Caching Strategy:
* - Uses Next.js 16 Data Cache with "use cache" directive
* - Automatic stale-while-revalidate (SWR): serves cached data instantly,
* revalidates in background when cache expires
* - Cache TTLs: 7 days (Porkbun and Cloudflare)
* - No manual cron jobs needed - Next.js handles revalidation automatically
* - Gracefully handles slow/failed API responses by returning null
*
* When registrar APIs are slow (common), users see cached pricing immediately
* while fresh data fetches in the background. This provides the best UX.
*/
/**
* Normalized pricing response shape that all registrars conform to.
* Maps TLD to pricing information: { "com": { "registration": "10.99", ... }, ... }
@@ -18,8 +33,6 @@ type RegistrarPricingResponse = Record<
interface PricingProvider {
/** Provider name for logging */
name: string;
/** Redis key for cached pricing data */
cacheKey: string;
/** How long to cache the pricing data (seconds) */
cacheTtlSeconds: number;
/** Fetch pricing data from the registrar API */
@@ -32,61 +45,21 @@ interface PricingProvider {
}
/**
* Generic function to fetch and cache pricing data from any provider.
* Uses simple fail-open caching without distributed locks.
* Fetch pricing data from a provider with Next.js Data Cache.
* Uses Next.js 16's "use cache" directive for automatic caching.
*/
async function fetchProviderPricing(
provider: PricingProvider,
): Promise<RegistrarPricingResponse | null> {
// Try cache first
const cached = await redis
.get<RegistrarPricingResponse | "error">(provider.cacheKey)
.catch((err) => {
console.error(
`[pricing] cache read error ${provider.name}`,
{ cacheKey: provider.cacheKey },
err instanceof Error ? err : new Error(String(err)),
);
return null;
});
if (cached && cached !== "error") return cached;
if (cached === "error") {
console.debug(`[pricing] cached failure ${provider.name}`);
return null;
}
// Fetch fresh pricing
try {
const payload = await provider.fetchPricing();
// Cache for next time
after(() => {
redis
.set(provider.cacheKey, payload, { ex: provider.cacheTtlSeconds })
.catch((err) => {
console.error(
`[pricing] cache write error ${provider.name}`,
{ cacheKey: provider.cacheKey },
err instanceof Error ? err : new Error(String(err)),
);
});
});
console.info(`[pricing] fetch ok ${provider.name} (not cached)`);
console.info(`[pricing] fetch ok ${provider.name}`);
return payload;
} catch (err) {
console.error(
`[pricing] fetch error ${provider.name}`,
err instanceof Error ? err : new Error(String(err)),
);
// Short TTL negative cache
after(() => {
redis.set(provider.cacheKey, "error", { ex: 60 }).catch((cacheErr) => {
console.error(
`[pricing] negative cache write error ${provider.name}`,
{ cacheKey: provider.cacheKey },
cacheErr instanceof Error ? cacheErr : new Error(String(cacheErr)),
);
});
});
return null;
}
}
@@ -97,7 +70,6 @@ async function fetchProviderPricing(
const porkbunProvider: PricingProvider = {
name: "porkbun",
cacheKey: ns("pricing:porkbun"),
cacheTtlSeconds: 7 * 24 * 60 * 60, // 7 days
async fetchPricing(): Promise<RegistrarPricingResponse> {
@@ -114,6 +86,10 @@ const porkbunProvider: PricingProvider = {
headers: { "Content-Type": "application/json" },
body: "{}",
signal: controller.signal,
next: {
revalidate: this.cacheTtlSeconds,
tags: ["pricing", "pricing-porkbun"],
},
},
);
@@ -145,7 +121,6 @@ const porkbunProvider: PricingProvider = {
const cloudflareProvider: PricingProvider = {
name: "cloudflare",
cacheKey: ns("pricing:cloudflare"),
cacheTtlSeconds: 7 * 24 * 60 * 60, // 7 days
async fetchPricing(): Promise<RegistrarPricingResponse> {
@@ -159,6 +134,10 @@ const cloudflareProvider: PricingProvider = {
method: "GET",
headers: { Accept: "application/json" },
signal: controller.signal,
next: {
revalidate: this.cacheTtlSeconds,
tags: ["pricing", "pricing-cloudflare"],
},
});
if (!res.ok) {
@@ -260,50 +239,3 @@ export async function getPricingForTld(domain: string): Promise<Pricing> {
return { tld, providers: availableProviders };
}
/**
* Proactively refresh pricing data from all providers.
* Called by the daily cron job to keep cache warm.
* Runs all provider refreshes in parallel for efficiency.
*/
export async function refreshAllProviderPricing(): Promise<{
refreshed: string[];
failed: string[];
}> {
const refreshed: string[] = [];
const failed: string[] = [];
// Run all provider refreshes in parallel
const tasks = providers.map(async (provider) => {
try {
const payload = await provider.fetchPricing();
await redis.set(provider.cacheKey, payload, {
ex: provider.cacheTtlSeconds,
});
return { name: provider.name, success: true as const };
} catch (err) {
return { name: provider.name, success: false as const, error: err };
}
});
const results = await Promise.allSettled(tasks);
// Process results and emit logs
for (const result of results) {
if (result.status === "fulfilled") {
const { name, success, error } = result.value;
if (success) {
refreshed.push(name);
console.info(`[pricing] refresh ok ${name}`);
} else {
failed.push(name);
console.error(`[pricing] refresh failed ${name}`, error);
}
} else {
// Promise itself was rejected (shouldn't happen with our error handling)
console.error("[pricing] refresh unexpected", result.reason);
}
}
return { refreshed, failed };
}

View File

@@ -6,11 +6,5 @@
"NPM_CONFIG_NODE_LINKER": "hoisted",
"PUPPETEER_SKIP_DOWNLOAD": "1"
}
},
"crons": [
{
"path": "/api/cron/pricing-refresh",
"schedule": "0 4 * * *"
}
]
}
}