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