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