You've already forked domainstack.io
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:
@@ -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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
import { after } from "next/server";
|
"use cache";
|
||||||
|
|
||||||
import { getDomainTld } from "rdapper";
|
import { getDomainTld } from "rdapper";
|
||||||
import { ns, redis } from "@/lib/redis";
|
|
||||||
import type { Pricing } from "@/lib/schemas";
|
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.
|
* Normalized pricing response shape that all registrars conform to.
|
||||||
* Maps TLD to pricing information: { "com": { "registration": "10.99", ... }, ... }
|
* Maps TLD to pricing information: { "com": { "registration": "10.99", ... }, ... }
|
||||||
@@ -18,8 +33,6 @@ type RegistrarPricingResponse = Record<
|
|||||||
interface PricingProvider {
|
interface PricingProvider {
|
||||||
/** Provider name for logging */
|
/** Provider name for logging */
|
||||||
name: string;
|
name: string;
|
||||||
/** Redis key for cached pricing data */
|
|
||||||
cacheKey: string;
|
|
||||||
/** How long to cache the pricing data (seconds) */
|
/** How long to cache the pricing data (seconds) */
|
||||||
cacheTtlSeconds: number;
|
cacheTtlSeconds: number;
|
||||||
/** Fetch pricing data from the registrar API */
|
/** Fetch pricing data from the registrar API */
|
||||||
@@ -32,61 +45,21 @@ interface PricingProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic function to fetch and cache pricing data from any provider.
|
* Fetch pricing data from a provider with Next.js Data Cache.
|
||||||
* Uses simple fail-open caching without distributed locks.
|
* Uses Next.js 16's "use cache" directive for automatic caching.
|
||||||
*/
|
*/
|
||||||
async function fetchProviderPricing(
|
async function fetchProviderPricing(
|
||||||
provider: PricingProvider,
|
provider: PricingProvider,
|
||||||
): Promise<RegistrarPricingResponse | null> {
|
): 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 {
|
try {
|
||||||
const payload = await provider.fetchPricing();
|
const payload = await provider.fetchPricing();
|
||||||
// Cache for next time
|
console.info(`[pricing] fetch ok ${provider.name}`);
|
||||||
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)`);
|
|
||||||
return payload;
|
return payload;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[pricing] fetch error ${provider.name}`,
|
`[pricing] fetch error ${provider.name}`,
|
||||||
err instanceof Error ? err : new Error(String(err)),
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +70,6 @@ async function fetchProviderPricing(
|
|||||||
|
|
||||||
const porkbunProvider: PricingProvider = {
|
const porkbunProvider: PricingProvider = {
|
||||||
name: "porkbun",
|
name: "porkbun",
|
||||||
cacheKey: ns("pricing:porkbun"),
|
|
||||||
cacheTtlSeconds: 7 * 24 * 60 * 60, // 7 days
|
cacheTtlSeconds: 7 * 24 * 60 * 60, // 7 days
|
||||||
|
|
||||||
async fetchPricing(): Promise<RegistrarPricingResponse> {
|
async fetchPricing(): Promise<RegistrarPricingResponse> {
|
||||||
@@ -114,6 +86,10 @@ const porkbunProvider: PricingProvider = {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: "{}",
|
body: "{}",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
next: {
|
||||||
|
revalidate: this.cacheTtlSeconds,
|
||||||
|
tags: ["pricing", "pricing-porkbun"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -145,7 +121,6 @@ const porkbunProvider: PricingProvider = {
|
|||||||
|
|
||||||
const cloudflareProvider: PricingProvider = {
|
const cloudflareProvider: PricingProvider = {
|
||||||
name: "cloudflare",
|
name: "cloudflare",
|
||||||
cacheKey: ns("pricing:cloudflare"),
|
|
||||||
cacheTtlSeconds: 7 * 24 * 60 * 60, // 7 days
|
cacheTtlSeconds: 7 * 24 * 60 * 60, // 7 days
|
||||||
|
|
||||||
async fetchPricing(): Promise<RegistrarPricingResponse> {
|
async fetchPricing(): Promise<RegistrarPricingResponse> {
|
||||||
@@ -159,6 +134,10 @@ const cloudflareProvider: PricingProvider = {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
next: {
|
||||||
|
revalidate: this.cacheTtlSeconds,
|
||||||
|
tags: ["pricing", "pricing-cloudflare"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -260,50 +239,3 @@ export async function getPricingForTld(domain: string): Promise<Pricing> {
|
|||||||
|
|
||||||
return { tld, providers: availableProviders };
|
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,11 +6,5 @@
|
|||||||
"NPM_CONFIG_NODE_LINKER": "hoisted",
|
"NPM_CONFIG_NODE_LINKER": "hoisted",
|
||||||
"PUPPETEER_SKIP_DOWNLOAD": "1"
|
"PUPPETEER_SKIP_DOWNLOAD": "1"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"crons": [
|
|
||||||
{
|
|
||||||
"path": "/api/cron/pricing-refresh",
|
|
||||||
"schedule": "0 4 * * *"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user