Skip to main content

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}