Skip to content

Commit b80b864

Browse files
committed
refactor: improve Cloudflare IP fetching with Redis caching and in-memory testing setup
1 parent 521dadb commit b80b864

File tree

2 files changed

+126
-22
lines changed

2 files changed

+126
-22
lines changed

lib/cloudflare.test.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
/* @vitest-environment node */
2-
import { afterEach, describe, expect, it, vi } from "vitest";
3-
import { isCloudflareIp } from "./cloudflare";
2+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
43

5-
// Mock cacheLife before importing the module
6-
vi.mock("next/cache", () => ({
7-
cacheLife: vi.fn(),
8-
}));
4+
let isCloudflareIp: typeof import("./cloudflare").isCloudflareIp;
95

106
describe("isCloudflareIp", () => {
11-
afterEach(() => {
7+
beforeAll(async () => {
8+
// Use in-memory Redis for testing
9+
const { makeInMemoryRedis } = await import("@/lib/redis-mock");
10+
const impl = makeInMemoryRedis();
11+
vi.doMock("@/lib/redis", () => impl);
12+
13+
// Import module AFTER mocks are set up
14+
const module = await import("./cloudflare");
15+
isCloudflareIp = module.isCloudflareIp;
16+
});
17+
18+
afterEach(async () => {
19+
// Reset Redis state between tests
20+
const { resetInMemoryRedis } = await import("@/lib/redis-mock");
21+
resetInMemoryRedis();
1222
vi.restoreAllMocks();
1323
});
1424

@@ -29,6 +39,35 @@ describe("isCloudflareIp", () => {
2939
expect(await isCloudflareIp("5.6.7.8")).toBe(false);
3040
expect(await isCloudflareIp("2001:db8::1")).toBe(true);
3141
expect(await isCloudflareIp("2001:dead::1")).toBe(false);
42+
43+
// Verify fetch was called only once (subsequent calls should use Redis cache)
44+
expect(fetchMock).toHaveBeenCalledTimes(1);
45+
46+
fetchMock.mockRestore();
47+
});
48+
49+
it("uses Redis cache for subsequent requests", async () => {
50+
const body = (data: unknown) =>
51+
new Response(JSON.stringify(data), { status: 200 });
52+
const fetchMock = vi.spyOn(global, "fetch").mockImplementation(async () =>
53+
body({
54+
result: {
55+
ipv4_cidrs: ["1.2.3.0/24"],
56+
ipv6_cidrs: ["2001:db8::/32"],
57+
},
58+
}),
59+
);
60+
61+
// First call should fetch from API
62+
await isCloudflareIp("1.2.3.4");
63+
expect(fetchMock).toHaveBeenCalledTimes(1);
64+
65+
// Second call should use Redis cache (React cache() only dedups within same request)
66+
// Note: In real scenarios, React's cache() would reset between requests,
67+
// but Redis cache would persist
68+
await isCloudflareIp("1.2.3.5");
69+
expect(fetchMock).toHaveBeenCalledTimes(1); // Still only 1 fetch
70+
3271
fetchMock.mockRestore();
3372
});
3473
});

lib/cloudflare.ts

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,28 @@
11
import "server-only";
2+
23
import * as ipaddr from "ipaddr.js";
3-
import { cacheLife } from "next/cache";
44
import { cache } from "react";
5+
import { acquireLockOrWaitForResult } from "@/lib/cache";
56
import { CLOUDFLARE_IPS_URL } from "@/lib/constants";
67
import { ipV4InCidr, ipV6InCidr } from "@/lib/ip";
8+
import { redis } from "@/lib/redis";
79

810
export interface CloudflareIpRanges {
911
ipv4Cidrs: string[];
1012
ipv6Cidrs: string[];
1113
}
1214

15+
const CACHE_KEY = "cloudflare:ip-ranges";
16+
const LOCK_KEY = "cloudflare:ip-ranges:lock";
17+
const CACHE_TTL_SECONDS = 604800; // 1 week
18+
1319
let lastLoadedIpv4Parsed: Array<[ipaddr.IPv4, number]> | undefined;
1420
let lastLoadedIpv6Parsed: Array<[ipaddr.IPv6, number]> | undefined;
1521

1622
/**
17-
* Fetch Cloudflare IP ranges with Cache Components.
18-
*
19-
* The IP ranges change infrequently (when Cloudflare expands infrastructure),
20-
* so we cache for 1 day using Next.js 16 Cache Components.
21-
*
22-
* Also wrapped in React's cache() for per-request deduplication.
23+
* Fetch Cloudflare IP ranges from their API.
2324
*/
24-
const getCloudflareIpRanges = cache(async (): Promise<CloudflareIpRanges> => {
25-
"use cache";
26-
cacheLife("days");
27-
25+
async function fetchCloudflareIpRanges(): Promise<CloudflareIpRanges> {
2826
const res = await fetch(CLOUDFLARE_IPS_URL);
2927

3028
if (!res.ok) {
@@ -33,12 +31,18 @@ const getCloudflareIpRanges = cache(async (): Promise<CloudflareIpRanges> => {
3331

3432
const data = await res.json();
3533

36-
const ranges: CloudflareIpRanges = {
34+
return {
3735
ipv4Cidrs: data.result?.ipv4_cidrs || [],
3836
ipv6Cidrs: data.result?.ipv6_cidrs || [],
3937
};
38+
}
4039

41-
// Pre-parse IPv4 CIDRs for fast sync/async checks
40+
/**
41+
* Parse IP ranges into ipaddr.js objects for fast matching.
42+
* Updates module-level cache for synchronous access.
43+
*/
44+
function parseAndCacheRanges(ranges: CloudflareIpRanges): void {
45+
// Pre-parse IPv4 CIDRs for fast matching
4246
try {
4347
lastLoadedIpv4Parsed = ranges.ipv4Cidrs
4448
.map((cidr) => {
@@ -55,7 +59,7 @@ const getCloudflareIpRanges = cache(async (): Promise<CloudflareIpRanges> => {
5559
lastLoadedIpv4Parsed = undefined;
5660
}
5761

58-
// Pre-parse IPv6 CIDRs for fast sync/async checks
62+
// Pre-parse IPv6 CIDRs for fast matching
5963
try {
6064
lastLoadedIpv6Parsed = ranges.ipv6Cidrs
6165
.map((cidr) => {
@@ -71,10 +75,71 @@ const getCloudflareIpRanges = cache(async (): Promise<CloudflareIpRanges> => {
7175
} catch {
7276
lastLoadedIpv6Parsed = undefined;
7377
}
78+
}
79+
80+
/**
81+
* Fetch Cloudflare IP ranges with Redis caching.
82+
*
83+
* The IP ranges change infrequently (when Cloudflare expands infrastructure),
84+
* so we cache for 1 day in Redis with distributed locking to prevent thundering herd.
85+
*
86+
* Also wrapped in React's cache() for per-request deduplication.
87+
*/
88+
const getCloudflareIpRanges = cache(async (): Promise<CloudflareIpRanges> => {
89+
let ranges = await redis.get<CloudflareIpRanges>(CACHE_KEY);
90+
91+
if (!ranges) {
92+
const lock = await acquireLockOrWaitForResult<CloudflareIpRanges>({
93+
lockKey: LOCK_KEY,
94+
resultKey: CACHE_KEY,
95+
lockTtl: 30,
96+
pollIntervalMs: 250,
97+
maxWaitMs: 20_000,
98+
});
99+
100+
if (lock.acquired) {
101+
try {
102+
ranges = await fetchCloudflareIpRanges();
103+
await redis.set(CACHE_KEY, ranges, {
104+
ex: CACHE_TTL_SECONDS,
105+
});
106+
parseAndCacheRanges(ranges);
107+
console.info("[cloudflare-ips] IP ranges fetched (not cached)");
108+
} catch (err) {
109+
console.error(
110+
"[cloudflare-ips] fetch error",
111+
err instanceof Error ? err : new Error(String(err)),
112+
);
113+
// Write a short-TTL negative cache to prevent hammering during outages
114+
try {
115+
await redis.set(CACHE_KEY, null, { ex: 60 });
116+
} catch (cacheErr) {
117+
console.warn("[cloudflare-ips] negative cache failed", cacheErr);
118+
}
119+
} finally {
120+
// Always release the lock so waiters don't stall
121+
try {
122+
await redis.del(LOCK_KEY);
123+
} catch (delErr) {
124+
console.warn("[cloudflare-ips] lock release failed", delErr);
125+
}
126+
}
127+
} else {
128+
ranges = lock.cachedResult;
129+
}
130+
}
74131

75-
return ranges;
132+
// If we got ranges from cache, ensure parsed versions are available
133+
if (ranges) {
134+
parseAndCacheRanges(ranges);
135+
}
136+
137+
return ranges ?? { ipv4Cidrs: [], ipv6Cidrs: [] };
76138
});
77139

140+
/**
141+
* Check if a given IP address is part of Cloudflare's IP ranges.
142+
*/
78143
export const isCloudflareIp = cache(async (ip: string): Promise<boolean> => {
79144
const ranges = await getCloudflareIpRanges();
80145

0 commit comments

Comments
 (0)