11import "server-only" ;
2+
23import * as ipaddr from "ipaddr.js" ;
3- import { cacheLife } from "next/cache" ;
44import { cache } from "react" ;
5+ import { acquireLockOrWaitForResult } from "@/lib/cache" ;
56import { CLOUDFLARE_IPS_URL } from "@/lib/constants" ;
67import { ipV4InCidr , ipV6InCidr } from "@/lib/ip" ;
8+ import { redis } from "@/lib/redis" ;
79
810export 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+
1319let lastLoadedIpv4Parsed : Array < [ ipaddr . IPv4 , number ] > | undefined ;
1420let 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+ */
78143export const isCloudflareIp = cache ( async ( ip : string ) : Promise < boolean > => {
79144 const ranges = await getCloudflareIpRanges ( ) ;
80145
0 commit comments