forma_server/sw.rs
1//! The service worker: opt-in, per application, and retired everywhere else.
2//!
3//! # The setting
4//!
5//! A worker is an application's choice, made once at start, before the first
6//! page renders and before `/sw.js` is served:
7//!
8//! ```no_run
9//! use forma_server::sw::{self, ServiceWorker};
10//!
11//! // Ledger: pages register /sw.js, which serves the worker its build wrote.
12//! sw::configure(ServiceWorker::for_app("ledger")?.enabled())?;
13//! # Ok::<(), Box<dyn std::error::Error>>(())
14//! ```
15//!
16//! Left unconfigured, the setting is [`ServiceWorker::off`]: no page registers
17//! a worker, and `/sw.js` serves the *retiring worker* in place of whatever
18//! the build embedded. A browser that installed a worker from an earlier
19//! release checks `/sw.js` for an update on its next visits, receives the
20//! retiring worker, which deletes Forma's caches and unregisters, and from then
21//! on loads every page and asset from the network. This is the kill switch
22//! that keeps turning the worker on a two-way door; it is served through 3.x.
23//!
24//! Rolling back an application's worker is turning `.enabled()` off and
25//! keeping the name: its retiring worker then deletes that application's own
26//! caches too. Turning it back on re-installs the worker on the next visit.
27//!
28//! # The cache-name contract (forma-server 0.2.2, `@getforma/build` 0.3.1)
29//!
30//! The build writes the production worker and the cache names; forma-server
31//! writes the registration and the retiring worker. They meet here:
32//!
33//! - **Application name**: 1 to 63 of `a-z`, `0-9` and `-`, starting with a
34//! letter or digit; the same string in the build's configuration and in
35//! [`ServiceWorker::for_app`].
36//! - **Cache name**: `forma:<app>:<build>`, where `<build>` is the build's
37//! identity as the build writes it (today the first 12 hex digits of
38//! `build_hash`) and carries no `:`. The `forma:` prefix (a colon, not v1's
39//! hyphen) is new, so no v1 or 0.3.0 worker, all of which delete `forma-`
40//! names, ever deletes one.
41//! - **v1's names** (builds up to 0.3.0): `forma-dev`, `forma-` and 12
42//! lowercase hex digits, and either with `-on-demand-v1`; exactly
43//! `^forma-(?:dev|[0-9a-f]{12})(?:-on-demand-v1)?$`. They cannot be traced
44//! to an application, so every Forma worker retires them.
45//! - **The production worker** of application `X` reads only its own cache,
46//! and on activation deletes v1's names and `forma:X:` caches other than its
47//! own; never another application's (`forma:Y:`, `forma:Xy:`) and never a
48//! cache outside these patterns. It calls neither `skipWaiting()` nor
49//! `clients.claim()`: a new worker waits until the previous one controls no
50//! tab.
51//! - **The retiring worker** deletes v1's names and, for a named application,
52//! `forma:X:` caches; then unregisters. It answers no request. It does call
53//! `skipWaiting()`, as a kill switch must: an open tab of the old build is
54//! handed to it at once and loses the old worker's cache, so its requests go
55//! to the network (and to the skew path if its build's assets are gone).
56//! That is the trade made on purpose: a planted entry stops being served from
57//! the navigation after the browser's update check, not when the last old
58//! tab closes.
59//!
60//! # One origin, one setting
61//!
62//! `/sw.js` and its scope `/` belong to the origin, so every process serving
63//! one origin must agree on the setting: during a rolling deploy from 0.2.1 a
64//! browser can alternate between registering and retiring until every
65//! instance runs the new release; and of two applications behind one proxy on
66//! one origin, only one can enable the worker, under its own name.
67//!
68//! `tests/js/retiring_worker.mjs` holds the contract's vectors as a table;
69//! the build's tests are to share them.
70
71use axum::{
72 http::header,
73 response::{IntoResponse, Response},
74};
75use rust_embed::Embed;
76use std::fmt;
77use std::sync::OnceLock;
78
79/// An application's service-worker setting. See the [module docs](self).
80#[derive(Debug, Clone, Default, PartialEq, Eq)]
81pub struct ServiceWorker {
82 app: Option<String>,
83 enabled: bool,
84}
85
86impl ServiceWorker {
87 /// The default: no page registers a worker, and `/sw.js` is the retiring
88 /// worker for v1's cache names.
89 pub fn off() -> Self {
90 Self::default()
91 }
92
93 /// Name the application, still off: `/sw.js` is the retiring worker,
94 /// which also deletes this application's own caches (`forma:<app>:...`).
95 /// Add [`ServiceWorker::enabled`] to turn the worker on.
96 ///
97 /// # Errors
98 ///
99 /// An [`AppNameError`] unless `app` is 1 to 63 of `a-z`, `0-9` and `-`,
100 /// starting with a letter or digit.
101 pub fn for_app(app: &str) -> Result<Self, AppNameError> {
102 let mut bytes = app.bytes();
103 let valid = !app.is_empty()
104 && app.len() <= 63
105 && matches!(bytes.next(), Some(b) if b.is_ascii_lowercase() || b.is_ascii_digit())
106 && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
107 if valid {
108 Ok(ServiceWorker {
109 app: Some(app.to_string()),
110 enabled: false,
111 })
112 } else {
113 Err(AppNameError {
114 name: app.to_string(),
115 })
116 }
117 }
118
119 /// Turn the worker on: every page registers `/sw.js`, and `/sw.js`
120 /// serves the worker the build embedded.
121 #[must_use]
122 pub fn enabled(mut self) -> Self {
123 self.enabled = true;
124 self
125 }
126
127 /// Whether pages register the worker.
128 pub fn is_enabled(&self) -> bool {
129 self.enabled
130 }
131
132 /// The application's name, if it gave one.
133 pub fn app(&self) -> Option<&str> {
134 self.app.as_deref()
135 }
136}
137
138/// An application name the cache-name contract cannot carry.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct AppNameError {
141 name: String,
142}
143
144impl fmt::Display for AppNameError {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 write!(
147 f,
148 "invalid service-worker application name {:?}: use 1 to 63 of a-z, 0-9 and -, \
149 starting with a letter or digit",
150 self.name
151 )
152 }
153}
154
155impl std::error::Error for AppNameError {}
156
157/// [`configure`] was called after the setting was fixed: by an earlier
158/// `configure`, or by the first page or `/sw.js` request, which read the
159/// default.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct AlreadyConfigured {
162 current: ServiceWorker,
163}
164
165impl AlreadyConfigured {
166 /// The setting in force, which stays.
167 pub fn current(&self) -> &ServiceWorker {
168 &self.current
169 }
170}
171
172impl fmt::Display for AlreadyConfigured {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 write!(
175 f,
176 "the service-worker setting is already fixed ({:?}); configure it once, at start, \
177 before the first page renders",
178 self.current
179 )
180 }
181}
182
183impl std::error::Error for AlreadyConfigured {}
184
185static SETTING: OnceLock<ServiceWorker> = OnceLock::new();
186
187/// Fix this process's service-worker setting. Call it once, at start, before
188/// the first page renders and before `/sw.js` is served.
189///
190/// One setting per process, because `/sw.js` is one URL per origin and every
191/// page's registration must agree with what that URL serves.
192///
193/// # Errors
194///
195/// [`AlreadyConfigured`] if it was set before, or if a render or `/sw.js`
196/// already read the default; the setting in force stays.
197pub fn configure(worker: ServiceWorker) -> Result<(), AlreadyConfigured> {
198 let mut offered = Some(worker);
199 let current = SETTING.get_or_init(|| offered.take().unwrap_or_default());
200 match offered {
201 None => Ok(()),
202 Some(_) => Err(AlreadyConfigured {
203 current: current.clone(),
204 }),
205 }
206}
207
208/// This process's setting: what [`configure`] set, or [`ServiceWorker::off`].
209/// Reading it fixes it.
210pub fn configured() -> &'static ServiceWorker {
211 SETTING.get_or_init(ServiceWorker::off)
212}
213
214/// The registration a page carries when the worker is on: the 0.2.1 script,
215/// nonced.
216pub(crate) fn registration_script(nonce: &str) -> String {
217 if configured().is_enabled() {
218 format!(
219 "<script nonce=\"{nonce}\">if('serviceWorker' in navigator)navigator.serviceWorker.register('/sw.js');</script>\n"
220 )
221 } else {
222 String::new()
223 }
224}
225
226/// The retiring worker's source, for `app` (or for no application).
227///
228/// `app` has passed [`ServiceWorker::for_app`], so it holds only `a-z`, `0-9`
229/// and `-`, and writing it into a JavaScript string needs no escaping.
230pub(crate) fn retiring_worker(app: Option<&str>) -> String {
231 let own = match app {
232 Some(app) => format!("\"forma:{app}:\""),
233 None => "null".to_string(),
234 };
235 format!(
236 r#"// forma-server: the retiring service worker.
237// It deletes Forma's v1 caches and this application's own, never another
238// application's, then unregisters. It answers no request, so every page and
239// asset loads from the network.
240const LEGACY = /^forma-(?:dev|[0-9a-f]{{12}})(?:-on-demand-v1)?$/;
241const OWN = {own};
242
243self.addEventListener('install', (event) => {{
244 event.waitUntil(self.skipWaiting());
245}});
246
247self.addEventListener('activate', (event) => {{
248 event.waitUntil((async () => {{
249 try {{
250 const names = await caches.keys();
251 await Promise.all(names
252 .filter((name) => LEGACY.test(name) || (OWN !== null && name.startsWith(OWN)))
253 .map((name) => caches.delete(name)));
254 }} catch (error) {{
255 // Storage may be unavailable (private browsing, quota); retire anyway.
256 }}
257 await self.registration.unregister();
258 }})());
259}});
260"#
261 )
262}
263
264/// The worker is on, but the build embedded no `sw.js`: pages register a
265/// worker that cannot install, and a browser keeps any worker it already had
266/// (as at 0.2.1). Said once per process, at the first such `/sw.js` request,
267/// the first point at which the embedded set is known.
268///
269/// Verified by: crates/forma-server/tests/service_worker_on.rs > "an_enabled_application_with_no_built_worker_is_told_once"
270fn warn_enabled_without_worker_once() {
271 use std::sync::atomic::{AtomicBool, Ordering};
272 static WARNED: AtomicBool = AtomicBool::new(false);
273 if !WARNED.swap(true, Ordering::Relaxed) {
274 tracing::warn!(
275 "the service worker is enabled (forma_server::sw::configure) but the build \
276 embedded no sw.js: /sw.js answers 404 and pages register a worker that cannot \
277 install. Build with @getforma/build's `app` (0.3.1) and a `serviceWorker` mode, \
278 or turn the worker off."
279 );
280 }
281}
282
283/// Axum handler: serve `/sw.js` with no-cache headers.
284///
285/// Mount at `/sw.js` in your router. With the worker [enabled](ServiceWorker::enabled)
286/// it serves the `sw.js` the build embedded (404 if there is none). Otherwise,
287/// the default from 0.2.2, it serves the retiring worker, whatever the build
288/// embedded (see the [module docs](self)).
289///
290/// Verified by: crates/forma-server/tests/service_worker_off.rs > "by_default_sw_js_is_the_retiring_worker_even_with_a_built_worker_embedded"
291/// Verified by: crates/forma-server/tests/service_worker_on.rs > "an_enabled_application_serves_its_built_worker"
292pub async fn serve_sw<A: Embed>() -> Response {
293 let setting = configured();
294 let body = if setting.is_enabled() {
295 match crate::assets::asset_bytes::<A>("sw.js") {
296 Some(data) => axum::body::Body::from(data),
297 None => {
298 warn_enabled_without_worker_once();
299 return axum::http::StatusCode::NOT_FOUND.into_response();
300 }
301 }
302 } else {
303 axum::body::Body::from(retiring_worker(setting.app()))
304 };
305 let mut builder = Response::builder()
306 .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
307 .header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
308 .header("service-worker-allowed", "/");
309 // The service worker is the highest-value script on the origin;
310 // it must not be sniffable into another type.
311 for (name, value) in crate::types::asset_security_headers() {
312 builder = builder.header(name, value);
313 }
314 // Static headers cannot fail to parse; if one ever did, answer 500 rather
315 // than panic or send the worker without its headers.
316 match builder.body(body) {
317 Ok(response) => response.into_response(),
318 Err(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(),
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use rust_embed::Embed;
326
327 #[derive(Embed)]
328 #[folder = "test_fixtures/"]
329 struct Fixtures;
330
331 #[tokio::test]
332 async fn serve_sw_sets_nosniff_and_no_store() {
333 let response = serve_sw::<Fixtures>().await;
334 let headers = response.headers();
335 assert_eq!(
336 headers
337 .get("x-content-type-options")
338 .map(|v| v.to_str().unwrap()),
339 Some("nosniff"),
340 "the service worker must not be sniffable into another type"
341 );
342 assert_eq!(
343 headers
344 .get(header::CONTENT_TYPE)
345 .map(|v| v.to_str().unwrap()),
346 Some("text/javascript; charset=utf-8")
347 );
348 assert!(headers
349 .get(header::CACHE_CONTROL)
350 .unwrap()
351 .to_str()
352 .unwrap()
353 .contains("no-store"));
354 }
355
356 #[test]
357 fn application_names_are_what_a_cache_name_can_carry() {
358 // The grammar as data, shared with @getforma/build's `app` (copied into
359 // forma-tools' `packages/build/tests/helpers/app-name-vectors.ts`): the
360 // two must accept exactly the same names, edges included.
361 let table: serde_json::Value =
362 serde_json::from_str(include_str!("../tests/js/app_name_vectors.json"))
363 .expect("the shared table is JSON");
364 let names = |key: &str| -> Vec<String> {
365 table[key]
366 .as_array()
367 .expect("a list")
368 .iter()
369 .map(|v| v.as_str().expect("a name").to_string())
370 .collect()
371 };
372 let (accepted, refused) = (names("accepted"), names("refused"));
373 // The edges the review found unpinned are in the table.
374 for edge in ["ledger-", "a", "0", "123", "a--b"] {
375 assert!(accepted.iter().any(|n| n == edge), "{edge}");
376 }
377 for name in &accepted {
378 let worker = ServiceWorker::for_app(name).unwrap_or_else(|e| panic!("{e}"));
379 assert_eq!(worker.app(), Some(name.as_str()));
380 assert!(!worker.is_enabled(), "naming does not turn it on");
381 assert!(worker.enabled().is_enabled());
382 }
383 for name in &refused {
384 let error = ServiceWorker::for_app(name).expect_err(name);
385 assert!(error.to_string().contains(&format!("{name:?}")), "{error}");
386 }
387 assert!(refused.len() >= 29, "the hostile names are all there");
388 }
389
390 #[test]
391 fn the_retiring_worker_deletes_v1_s_caches_and_its_own_never_another_s() {
392 // The worker's behaviour, run by Node in a stand-in service-worker
393 // scope over the contract's cache-name vectors. Node (18 or later) is
394 // required: a missing `node` fails this test, it does not skip it.
395 let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/js/retiring_worker.mjs");
396 // The script writes how many tests passed here, so this check does not
397 // read Node's reporter output (which NODE_OPTIONS can change).
398 let count_file = std::env::temp_dir().join(format!(
399 "forma-retiring-worker-{}-{:?}.txt",
400 std::process::id(),
401 std::thread::current().id()
402 ));
403 let _ = std::fs::remove_file(&count_file);
404 let output = std::process::Command::new("node")
405 .arg("--test")
406 .arg(script)
407 .env("FORMA_RETIRING_UNNAMED", retiring_worker(None))
408 .env("FORMA_RETIRING_LEDGER", retiring_worker(Some("ledger")))
409 .env("FORMA_RETIRING_REPORT", &count_file)
410 .output()
411 .unwrap_or_else(|e| panic!("this test runs the worker in Node 18+, not found: {e}"));
412 let report = format!(
413 "{}{}",
414 String::from_utf8_lossy(&output.stdout),
415 String::from_utf8_lossy(&output.stderr)
416 );
417 assert!(output.status.success(), "{report}");
418 let passed = std::fs::read_to_string(&count_file)
419 .unwrap_or_else(|e| panic!("the script reported no count ({e}):\n{report}"));
420 let _ = std::fs::remove_file(&count_file);
421 // One hand-over check, then three checks for each of the two sources.
422 assert_eq!(passed, "7", "{report}");
423 }
424}