use axum::{
http::header,
response::{IntoResponse, Response},
};
use rust_embed::Embed;
use std::fmt;
use std::sync::OnceLock;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServiceWorker {
app: Option<String>,
enabled: bool,
}
impl ServiceWorker {
pub fn off() -> Self {
Self::default()
}
pub fn for_app(app: &str) -> Result<Self, AppNameError> {
let mut bytes = app.bytes();
let valid = !app.is_empty()
&& app.len() <= 63
&& matches!(bytes.next(), Some(b) if b.is_ascii_lowercase() || b.is_ascii_digit())
&& bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if valid {
Ok(ServiceWorker {
app: Some(app.to_string()),
enabled: false,
})
} else {
Err(AppNameError {
name: app.to_string(),
})
}
}
#[must_use]
pub fn enabled(mut self) -> Self {
self.enabled = true;
self
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn app(&self) -> Option<&str> {
self.app.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppNameError {
name: String,
}
impl fmt::Display for AppNameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid service-worker application name {:?}: use 1 to 63 of a-z, 0-9 and -, \
starting with a letter or digit",
self.name
)
}
}
impl std::error::Error for AppNameError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AlreadyConfigured {
current: ServiceWorker,
}
impl AlreadyConfigured {
pub fn current(&self) -> &ServiceWorker {
&self.current
}
}
impl fmt::Display for AlreadyConfigured {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"the service-worker setting is already fixed ({:?}); configure it once, at start, \
before the first page renders",
self.current
)
}
}
impl std::error::Error for AlreadyConfigured {}
static SETTING: OnceLock<ServiceWorker> = OnceLock::new();
fn set(worker: ServiceWorker) -> Result<(), AlreadyConfigured> {
let mut offered = Some(worker);
let current = SETTING.get_or_init(|| offered.take().unwrap_or_default());
match offered {
None => Ok(()),
Some(_) => Err(AlreadyConfigured {
current: current.clone(),
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigureError {
AlreadyConfigured(AlreadyConfigured),
ManifestRequired,
InvalidManifest(String),
ApplicationMismatch {
manifest: String,
worker: String,
},
}
impl ConfigureError {
pub fn current(&self) -> Option<&ServiceWorker> {
match self {
Self::AlreadyConfigured(e) => Some(e.current()),
_ => None,
}
}
}
impl fmt::Display for ConfigureError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::AlreadyConfigured(e)=>e.fmt(f),
Self::ManifestRequired=>f.write_str("enabled service worker requires configure_for_manifest(worker, &manifest) before binding"),
Self::InvalidManifest(e)=>f.write_str(e),
Self::ApplicationMismatch{manifest,worker}=>write!(f,"MANIFEST-104 manifest application {manifest:?} differs from worker for_app {worker:?}"),
}
}
}
impl std::error::Error for ConfigureError {}
pub fn configure(worker: ServiceWorker) -> Result<(), ConfigureError> {
if let Some(current) = SETTING.get() {
return Err(ConfigureError::AlreadyConfigured(AlreadyConfigured {
current: current.clone(),
}));
}
if worker.is_enabled() {
return Err(ConfigureError::ManifestRequired);
}
set(worker).map_err(ConfigureError::AlreadyConfigured)
}
pub fn configure_for_manifest(
worker: ServiceWorker,
manifest: &crate::AssetManifest,
) -> Result<(), ConfigureError> {
manifest
.validate()
.map_err(ConfigureError::InvalidManifest)?;
if worker.is_enabled() && worker.app() != Some(manifest.app.as_str()) {
return Err(ConfigureError::ApplicationMismatch {
manifest: manifest.app.clone(),
worker: worker.app().unwrap_or_default().to_string(),
});
}
set(worker).map_err(ConfigureError::AlreadyConfigured)
}
pub(crate) fn validate_manifest_app(manifest: &crate::AssetManifest) -> Result<(), String> {
if let Some(worker) = SETTING.get().filter(|w| w.is_enabled()) {
if worker.app() != Some(manifest.app.as_str()) {
return Err(ConfigureError::ApplicationMismatch {
manifest: manifest.app.clone(),
worker: worker.app().unwrap_or_default().into(),
}
.to_string());
}
}
Ok(())
}
pub fn configured() -> &'static ServiceWorker {
SETTING.get_or_init(ServiceWorker::off)
}
pub(crate) fn registration_script(nonce: &str) -> String {
if configured().is_enabled() {
format!(
"<script nonce=\"{nonce}\">if('serviceWorker' in navigator)navigator.serviceWorker.register('/sw.js');</script>\n"
)
} else {
String::new()
}
}
pub(crate) fn retiring_worker(app: Option<&str>) -> String {
let own = match app {
Some(app) => format!("\"forma:{app}:\""),
None => "null".to_string(),
};
format!(
r#"// forma-server: the retiring service worker.
// It deletes Forma's v1 caches and this application's own, never another
// application's, then unregisters. It answers no request, so every page and
// asset loads from the network.
const LEGACY = /^forma-(?:dev|[0-9a-f]{{12}})(?:-on-demand-v1)?$/;
const OWN = {own};
self.addEventListener('install', (event) => {{
event.waitUntil(self.skipWaiting());
}});
self.addEventListener('activate', (event) => {{
event.waitUntil((async () => {{
try {{
const names = await caches.keys();
await Promise.all(names
.filter((name) => LEGACY.test(name) || (OWN !== null && name.startsWith(OWN)))
.map((name) => caches.delete(name)));
}} catch (error) {{
// Storage may be unavailable (private browsing, quota); retire anyway.
}}
await self.registration.unregister();
}})());
}});
"#
)
}
fn warn_enabled_without_worker_once() {
use std::sync::atomic::{AtomicBool, Ordering};
static WARNED: AtomicBool = AtomicBool::new(false);
if !WARNED.swap(true, Ordering::Relaxed) {
tracing::warn!(
"the service worker is enabled (forma_server::sw::configure) but the build \
embedded no sw.js: /sw.js answers 404 and pages register a worker that cannot \
install. Build with @getforma/build's `app` (0.3.1) and a `serviceWorker` mode, \
or turn the worker off."
);
}
}
pub async fn serve_sw<A: Embed>() -> Response {
let setting = configured();
let body = if setting.is_enabled() {
match crate::assets::asset_bytes::<A>("sw.js") {
Some(data) => axum::body::Body::from(data),
None => {
warn_enabled_without_worker_once();
return axum::http::StatusCode::NOT_FOUND.into_response();
}
}
} else {
axum::body::Body::from(retiring_worker(setting.app()))
};
let mut builder = Response::builder()
.header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate")
.header("service-worker-allowed", "/");
for (name, value) in crate::types::asset_security_headers() {
builder = builder.header(name, value);
}
match builder.body(body) {
Ok(response) => response.into_response(),
Err(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_embed::Embed;
#[derive(Embed)]
#[folder = "test_fixtures/"]
struct Fixtures;
#[tokio::test]
async fn serve_sw_sets_nosniff_and_no_store() {
let response = serve_sw::<Fixtures>().await;
let headers = response.headers();
assert_eq!(
headers
.get("x-content-type-options")
.map(|v| v.to_str().unwrap()),
Some("nosniff"),
"the service worker must not be sniffable into another type"
);
assert_eq!(
headers
.get(header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap()),
Some("text/javascript; charset=utf-8")
);
assert!(headers
.get(header::CACHE_CONTROL)
.unwrap()
.to_str()
.unwrap()
.contains("no-store"));
}
#[test]
fn application_names_are_what_a_cache_name_can_carry() {
let table: serde_json::Value =
serde_json::from_str(include_str!("../tests/js/app_name_vectors.json"))
.expect("the shared table is JSON");
let names = |key: &str| -> Vec<String> {
table[key]
.as_array()
.expect("a list")
.iter()
.map(|v| v.as_str().expect("a name").to_string())
.collect()
};
let (accepted, refused) = (names("accepted"), names("refused"));
for edge in ["ledger-", "a", "0", "123", "a--b"] {
assert!(accepted.iter().any(|n| n == edge), "{edge}");
}
for name in &accepted {
let worker = ServiceWorker::for_app(name).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(worker.app(), Some(name.as_str()));
assert!(!worker.is_enabled(), "naming does not turn it on");
assert!(worker.enabled().is_enabled());
}
for name in &refused {
let error = ServiceWorker::for_app(name).expect_err(name);
assert!(error.to_string().contains(&format!("{name:?}")), "{error}");
}
assert!(refused.len() >= 29, "the hostile names are all there");
}
#[test]
fn the_retiring_worker_deletes_v1_s_caches_and_its_own_never_another_s() {
let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/js/retiring_worker.mjs");
let count_file = std::env::temp_dir().join(format!(
"forma-retiring-worker-{}-{:?}.txt",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_file(&count_file);
let output = std::process::Command::new("node")
.arg("--test")
.arg(script)
.env("FORMA_RETIRING_UNNAMED", retiring_worker(None))
.env("FORMA_RETIRING_LEDGER", retiring_worker(Some("ledger")))
.env("FORMA_RETIRING_REPORT", &count_file)
.output()
.unwrap_or_else(|e| panic!("this test runs the worker in Node 18+, not found: {e}"));
let report = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(output.status.success(), "{report}");
let passed = std::fs::read_to_string(&count_file)
.unwrap_or_else(|e| panic!("the script reported no count ({e}):\n{report}"));
let _ = std::fs::remove_file(&count_file);
assert_eq!(passed, "7", "{report}");
}
}