Skip to content

Commit 0bd9ef7

Browse files
vranaclaude
andcommitted
Compile: Cache the assets in a service worker
The stylesheet, scripts and logo are sent with a year-long expiration but some hosts forbid caching anything their interface generates - cPanel's cpsrvd sends no-store - so every page downloaded them again. The Cache Storage is filled by explicit put() calls, which those headers don't reach. The worker configures itself from its own ?file=worker.js&version= URL and touches only the files of that version, leaving everything else, navigations included, to the browser. It is registered with location.pathname as the scope, the narrowest one allowed, so it never sees the rest of the origin. Adminer can be logged in to several connections at once, so the worker is unregistered - and its caches deleted, which unregistering alone doesn't do - only once none is left. That is also why the login form doesn't register it back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d08cd9e commit 0bd9ef7

5 files changed

Lines changed: 75 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- Alter routine: Preserve all characteristics and edit them in a form instead of in the definition
44
- Autocomplete: Offer also in the definition of a routine, an event and a view
55
- RTL: Mirror the borders, alignment, margins and paddings
6+
- Cache the assets of the compiled version in a service worker
67
- MySQL: Display the check constraint clause without the extra escaping added by information_schema
78
- MySQL 9: Create routines with LANGUAGE JAVASCRIPT
89
- MS SQL: Get the base type of a column declared with a user-defined type

adminer/file.inc.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
../adminer/static/jush/modules/jush-sqlite.js;
3535
../adminer/static/jush/modules/jush-mssql.js;
3636
../adminer/static/jush/modules/jush-oracle.js', 'minify_js'));
37+
} elseif ($_GET["file"] == "worker.js") {
38+
header("Content-Type: text/javascript; charset=utf-8");
39+
echo decompress_string(compile_file('../adminer/static/worker.js', 'minify_js'));
3740
} elseif ($_GET["file"] == "logo.png") {
3841
header("Content-Type: image/png");
3942
echo compile_file('../adminer/static/logo.png');

adminer/include/design.inc.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ function page_header(string $title, string $error = "", $breadcrumb = array(), s
101101
echo "<div id='ajaxstatus' role='status' class='jsonly'></div>\n";
102102
restart_session();
103103
page_messages($error);
104+
if (!defined('Adminer\DIR')) { // only the compiled version serves the files itself, the development version leaves them to the web server
105+
service_worker();
106+
}
104107
$databases = &get_session("dbs");
105108
if (DB != "" && $databases && !in_array(DB, $databases, true)) {
106109
$databases = null;
@@ -112,6 +115,30 @@ function page_header(string $title, string $error = "", $breadcrumb = array(), s
112115
flush();
113116
}
114117

118+
/** Print the script maintaining the service worker caching the static files */
119+
function service_worker(): void {
120+
$code = (has_passwords() // the worker belongs to all connections at once, so it is removed only after logging out of the last one; the login form must not register it back
121+
? "navigator.serviceWorker.register('" . js_escape(preg_replace('~\?.*~', '', ME) . "?file=worker.js&version=" . VERSION) . "', {scope: location.pathname}).catch(() => {});"
122+
: "navigator.serviceWorker.getRegistration().then(registration => registration && registration.unregister());
123+
caches.keys().then(keys => keys.forEach(key => key.startsWith('adminer-') && caches.delete(key)));"
124+
);
125+
echo script("if (navigator.serviceWorker) {\n\t$code\n}");
126+
}
127+
128+
/** Check whether any connection is logged in */
129+
function has_passwords(): bool {
130+
foreach ((array) $_SESSION["pwds"] as $servers) {
131+
foreach ($servers as $usernames) {
132+
foreach ($usernames as $password) {
133+
if ($password !== null) { // logging out sets the password to null, it doesn't remove the key
134+
return true;
135+
}
136+
}
137+
}
138+
}
139+
return false;
140+
}
141+
115142
/** Send HTTP headers */
116143
function page_headers(): void {
117144
header("Content-Type: text/html; charset=utf-8");

adminer/static/worker.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use strict';
2+
3+
// Serve the static files of the compiled Adminer from the Cache Storage.
4+
// The HTTP cache is enough almost everywhere but some hosts forbid caching anything their
5+
// interface generates, and Adminer generates its own files. Cache Storage is filled by
6+
// explicit put() calls so those headers don't apply to it.
7+
8+
// this script is served from <Adminer URL>?file=worker.js&version=<version>, the rest follows from that
9+
const prefix = self.location.href.replace(/worker\.js&version=.*/, '');
10+
const version = self.location.href.replace(/.*&version=/, '');
11+
const cacheName = 'adminer-' + version;
12+
13+
// don't wait for the tabs of the previous version to close
14+
self.addEventListener('install', () => self.skipWaiting());
15+
16+
self.addEventListener('activate', event => {
17+
event.waitUntil(self.clients.claim().then(() => caches.keys()).then(keys => Promise.all(
18+
keys.filter(key => key != cacheName && key.startsWith('adminer-')).map(key => caches.delete(key))
19+
)));
20+
});
21+
22+
self.addEventListener('fetch', event => {
23+
const url = event.request.url;
24+
if (
25+
event.request.method != 'GET'
26+
|| !url.startsWith(prefix)
27+
|| !url.endsWith('&version=' + version) // a file of another version, its cache is deleted on activate
28+
|| url == self.location.href // the browser fetches this script bypassing this handler but a cached service worker could never be replaced
29+
) {
30+
return; // not ours - do nothing, which lets the browser handle it as if there was no service worker
31+
}
32+
event.respondWith(caches.open(cacheName)
33+
.then(cache => cache.match(event.request).then(cached => cached || fetch(event.request).then(response => {
34+
if (response.ok) {
35+
// the version in the URL makes the file immutable so it is never revalidated; waitUntil keeps the worker alive until it is stored
36+
event.waitUntil(cache.put(event.request, response.clone()));
37+
}
38+
return response;
39+
})))
40+
.catch(() => fetch(event.request)) // a broken cache must not make Adminer unreachable
41+
);
42+
});

docs/developing.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,8 @@ The website translations are managed at https://www.adminer.org/en/translations/
403403
Adminer’s source code is divided into a manageable number of reasonably small files.
404404
For simpler deployment, these files are bundled into a single `*.php` file by inlining `include` files.
405405
Static files (`*.js`, `*.css`) are also inlined and served via the `?file=` route.
406+
They are sent with a year-long expiration but some hosts forbid caching anything their interface generates, so a service worker ([`worker.js`](/adminer/static/worker.js)) keeps them in the Cache Storage, which those headers don't reach.
407+
It is registered only by the compiled version and unregistered after the last logout.
406408

407409
Includes in Adminer start with `./` to bypass `include_path`, which is unrelated to compilation.
408410

0 commit comments

Comments
 (0)