Is your feature request related to a problem? Please describe.
On shared hosting a cache hit still costs PHP. The request is matched against the rewrite section, WordPress boots, and the buffer layer hands the file over; under load that is the difference between a cached page that stays fast and one that queues behind PHP-FPM workers taken by neighbouring sites. The same rewrite section is read again for every asset request on the page.
Shared hosting is where a large share of WP Rocket installations live, CloudLinux OS is the platform most of those hosts run, and their hosts get more capacity from the same hardware - which is what they weigh when deciding which caching plugin to recommend and bundle.
Describe the solution you'd like
CloudLinux OS ships a caching module for Apache (mod_maxcache) and NGINX (ngx_http_maxcache_module) that hands a page cache file to a visitor without starting PHP and without evaluating rewrite rules. It reads its configuration from .htaccess, so a plugin that already writes that file can tell it where its cache lives and which visitors may be served from it. WP Rocket already produces exactly the files such a module needs; what is missing is the description of its cache layout. How the cache is built does not change: the same files, in the same place, with the same names.
A one-click add-on that supplies that description:
- In the Add-ons tab, registered only where the module is installed. On every other installation nothing changes and nothing new appears.
- When switched on, the add-on adds an
<IfModule maxcache_module> block to the file WP Rocket already writes, describing the cache path and the exclusions, and takes the plugin's own serving rules out of that block: exactly one component may answer a request.
- When switched off, when the module is removed from the host, or when the configuration turns into one the module cannot reproduce, the directives leave the file and - on Apache, where those rules are what serves the cache - the plugin's serving rules come back. Deactivating the plugin takes the add-on's own directives out too, by their own markers, so nothing is left serving for a plugin that is no longer installed.
- The add-on refuses, and says why in the Add-ons tab, on: multisite (where the plugin itself serves nothing by request URI), page caching off, a proxy that decides the request scheme while
cache_ssl is on, mobile caching off, HTTPS with cache_ssl off, rocket_url_no_dots, nested dynamic cookies, a value that cannot be written into a server directive as it stands, and a cache directory the web server cannot address. It also refuses whenever another integration has already vetoed URI-level delivery through rocket_htaccess_mod_rewrite.
- Where a configuration affects only part of the traffic, the add-on serves the rest instead of standing down, and says so. Caching for logged-in users is the case that matters: those visitors are left to the plugin, because the per-user directory is named from a value the visitor sends, while anonymous pages - named identically either way - go on being served by the module. This is only done where the narrowing excludes a class of requests; one that would instead include what the plugin excludes, or change which variant a visitor gets, stays a refusal.
- On upgrade, on a host that has the module, the add-on switches itself on once and says so in a notice. Any answer the user has given is never overridden.
Besides the add-on itself and its settings field, this needs four hooks and one constant, all of them useful to any consumer that reads .htaccess rather than being the web server:
rocket_htaccess_needed_without_apache - a filter through which such a consumer says the file is still needed on a server that is not Apache. Without a listener, behaviour is unchanged.
rocket_after_flush_htaccess - an action fired once the file holds the current rules, so such a consumer can be told. A short or failed write does not fire it.
rocket_maxcache_available and rocket_maxcache_status - the settings page asks the add-on whether to offer the switch and what to report.
- A constant on
Buffer\Tests for the query-string parameters a page is always cacheable with, so that such a consumer reads the plugin's own list instead of keeping a copy of it that drifts.
It also hardens the writer it now depends on, and those three changes apply to every installation: a block whose opening marker has lost its closing one is no longer rewritten at all - the old code took everything after that marker with it, including other vendors' rules; a removal on a file whose block is empty now takes the markers out instead of returning early and leaving them behind; and a write cut short by a full disk or a quota puts the file back the way it was and is no longer reported as a success.
One more change is shared rather than added: the lines that tell the server what a .html_gzip file is move out of the plugin's rewrite rules into a function of their own, so that whichever component is serving writes the same ones. Nothing else in the plugin's shared classes changes, and the add-on stores no option of its own: its switch is a key in the settings row the plugin already writes.
Describe alternatives you've considered
- A plugin outside WP Rocket. The module has to reproduce the plugin's cache-path layout exactly: mobile and tablet variants, SSL, WebP, dynamic cookies, gzip, the logged-in bucket, plus every URI, user-agent and cookie exclusion. Only the plugin knows what that layout is for a given set of settings. Anything built outside has to infer it, and an inference that is wrong is a silent cache miss at best and the wrong variant served to the wrong visitor at worst.
- Wrapping the plugin's own rules in
<IfModule !maxcache_module> instead of removing them. That leaves a section in the file the server never reads, and the file stops saying plainly which component owns delivery. Removing them says it in one place, and the server carries nothing it never uses.
- Describing the layout on the module's side only. The same inference problem, one layer further away: the module would have to track the plugin's naming rules release by release, and a mismatch surfaces as a wrong page rather than as an error.
Additional context
Acceptance criteria:
- On a host without the module, the plugin behaves exactly as it does today: no new switch, no new writes, no new requests.
- On a host with the module and the switch on, a cached page is answered by the module, and the plugin's serving rules are not in
.htaccess.
- Turning the switch off, removing the module from the host, or moving to a configuration the module cannot reproduce puts delivery back to the plugin without any further action by the user.
- Deactivating or uninstalling the plugin leaves no MAx Cache directives or transients behind.
- Every configuration the add-on cannot reproduce exactly is either refused or narrowed to the traffic it can serve, never approximated, and either way the reason is visible in the Add-ons tab.
The module is part of CloudLinux OS. It can be tested on a CloudLinux host with the module installed, or on any host by creating the paths the add-on detects, in which case everything except the final delivery behaves identically. The module sets no header to say it answered a request; the way to tell the two apart is to have PHP mark the pages it rendered, so a page that comes back unmarked was answered before PHP started. A pull request implementing this is ready and will be linked here; the steps for both ways of testing are in it.
It depends on #8786, which bounds every component of the cache path rather than only the file name, as the module does: until both sides bound them the same way, a path with a component of around 240 bytes is written under one name and looked for under another. One limitation is worth stating here rather than being discovered: the plugin names the cache directory from the request URI as it arrives, percent-encoded, while the module resolves the path the web server has already decoded, so a URL carrying encoded characters is a miss and its page is served by the plugin from PHP, as it is today.
Is your feature request related to a problem? Please describe.
On shared hosting a cache hit still costs PHP. The request is matched against the rewrite section, WordPress boots, and the buffer layer hands the file over; under load that is the difference between a cached page that stays fast and one that queues behind PHP-FPM workers taken by neighbouring sites. The same rewrite section is read again for every asset request on the page.
Shared hosting is where a large share of WP Rocket installations live, CloudLinux OS is the platform most of those hosts run, and their hosts get more capacity from the same hardware - which is what they weigh when deciding which caching plugin to recommend and bundle.
Describe the solution you'd like
CloudLinux OS ships a caching module for Apache (
mod_maxcache) and NGINX (ngx_http_maxcache_module) that hands a page cache file to a visitor without starting PHP and without evaluating rewrite rules. It reads its configuration from.htaccess, so a plugin that already writes that file can tell it where its cache lives and which visitors may be served from it. WP Rocket already produces exactly the files such a module needs; what is missing is the description of its cache layout. How the cache is built does not change: the same files, in the same place, with the same names.A one-click add-on that supplies that description:
<IfModule maxcache_module>block to the file WP Rocket already writes, describing the cache path and the exclusions, and takes the plugin's own serving rules out of that block: exactly one component may answer a request.cache_sslis on, mobile caching off, HTTPS withcache_ssloff,rocket_url_no_dots, nested dynamic cookies, a value that cannot be written into a server directive as it stands, and a cache directory the web server cannot address. It also refuses whenever another integration has already vetoed URI-level delivery throughrocket_htaccess_mod_rewrite.Besides the add-on itself and its settings field, this needs four hooks and one constant, all of them useful to any consumer that reads
.htaccessrather than being the web server:rocket_htaccess_needed_without_apache- a filter through which such a consumer says the file is still needed on a server that is not Apache. Without a listener, behaviour is unchanged.rocket_after_flush_htaccess- an action fired once the file holds the current rules, so such a consumer can be told. A short or failed write does not fire it.rocket_maxcache_availableandrocket_maxcache_status- the settings page asks the add-on whether to offer the switch and what to report.Buffer\Testsfor the query-string parameters a page is always cacheable with, so that such a consumer reads the plugin's own list instead of keeping a copy of it that drifts.It also hardens the writer it now depends on, and those three changes apply to every installation: a block whose opening marker has lost its closing one is no longer rewritten at all - the old code took everything after that marker with it, including other vendors' rules; a removal on a file whose block is empty now takes the markers out instead of returning early and leaving them behind; and a write cut short by a full disk or a quota puts the file back the way it was and is no longer reported as a success.
One more change is shared rather than added: the lines that tell the server what a
.html_gzipfile is move out of the plugin's rewrite rules into a function of their own, so that whichever component is serving writes the same ones. Nothing else in the plugin's shared classes changes, and the add-on stores no option of its own: its switch is a key in the settings row the plugin already writes.Describe alternatives you've considered
<IfModule !maxcache_module>instead of removing them. That leaves a section in the file the server never reads, and the file stops saying plainly which component owns delivery. Removing them says it in one place, and the server carries nothing it never uses.Additional context
Acceptance criteria:
.htaccess.The module is part of CloudLinux OS. It can be tested on a CloudLinux host with the module installed, or on any host by creating the paths the add-on detects, in which case everything except the final delivery behaves identically. The module sets no header to say it answered a request; the way to tell the two apart is to have PHP mark the pages it rendered, so a page that comes back unmarked was answered before PHP started. A pull request implementing this is ready and will be linked here; the steps for both ways of testing are in it.
It depends on #8786, which bounds every component of the cache path rather than only the file name, as the module does: until both sides bound them the same way, a path with a component of around 240 bytes is written under one name and looked for under another. One limitation is worth stating here rather than being discovered: the plugin names the cache directory from the request URI as it arrives, percent-encoded, while the module resolves the path the web server has already decoded, so a URL carrying encoded characters is a miss and its page is served by the plugin from PHP, as it is today.