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}