use std::collections::BTreeMap;
use std::path::Path;
use std::time::Duration;
use anyhow::Context as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use yah_mesofact_bundle::{BundleHash, BundleManifest};
use crate::config::{DomainConfig, FrontDoor};
pub const BEACON_KEY: &str = ".well-known/yah-publish.json";
pub const BUNDLE_BEACON_PATH: &str = "app/dist/html/.well-known/yah-publish.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublishBeacon {
pub prefix: String,
#[serde(default)]
pub published_at: Option<String>,
pub digest: String,
pub files: usize,
}
impl PublishBeacon {
pub fn new(prefix: &str, contents: &BTreeMap<String, String>) -> Self {
Self {
prefix: prefix.trim_end_matches('/').to_string(),
published_at: Some(chrono::Utc::now().to_rfc3339()),
digest: digest_of(contents),
files: contents.len(),
}
}
pub fn for_bundle(name: &str, contents: &BTreeMap<String, String>) -> Self {
Self {
prefix: bundle_prefix(name),
published_at: None,
digest: digest_of(contents),
files: contents.len(),
}
}
pub fn published_label(&self) -> &str {
self.published_at
.as_deref()
.unwrap_or("(content-addressed bundle — no publish clock)")
}
pub fn is_bundle_stamp(&self) -> bool {
self.published_at.is_none()
}
pub fn verify_serving_slot(&self) -> &'static str {
if self.is_bundle_stamp() {
"[providers.bundle]"
} else {
"[providers.static]"
}
}
}
pub fn bundle_prefix(name: &str) -> String {
format!("bundle/{name}")
}
pub fn digest_of(contents: &BTreeMap<String, String>) -> String {
let mut h = Sha256::new();
for (key, hash) in contents {
h.update(key.as_bytes());
h.update([0u8]);
h.update(hash.as_bytes());
h.update(b"\n");
}
hex::encode(h.finalize())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServedState {
Match,
Stale { served: PublishBeacon },
NotABeacon { status: u16, body_preview: String },
Missing { status: u16 },
Unreachable { error: String },
}
impl ServedState {
pub fn is_match(&self) -> bool {
matches!(self, ServedState::Match)
}
pub fn summary(&self) -> String {
match self {
ServedState::Match => "OK (digest matches)".to_string(),
ServedState::Stale { served } => format!(
"STALE — serving digest {} published {} ({} files)",
short(&served.digest),
served.published_label(),
served.files
),
ServedState::NotABeacon {
status,
body_preview,
} => format!("HTTP {status} but not a publish beacon: {body_preview}"),
ServedState::Missing { status } => format!("HTTP {status}"),
ServedState::Unreachable { error } => format!("unreachable: {error}"),
}
}
}
fn short(digest: &str) -> String {
digest.chars().take(12).collect()
}
pub fn classify(status: u16, body: &[u8], expected: &PublishBeacon) -> ServedState {
if !(200..300).contains(&status) {
return ServedState::Missing { status };
}
match serde_json::from_slice::<PublishBeacon>(body) {
Ok(served) if served.digest == expected.digest => ServedState::Match,
Ok(served) => ServedState::Stale { served },
Err(_) => ServedState::NotABeacon {
status,
body_preview: preview(body),
},
}
}
fn preview(body: &[u8]) -> String {
let text = String::from_utf8_lossy(body);
let flat: String = text
.chars()
.map(|c| if c.is_whitespace() { ' ' } else { c })
.collect();
let trimmed = flat.split_whitespace().collect::<Vec<_>>().join(" ");
if trimmed.chars().count() > 120 {
format!("{}…", trimmed.chars().take(120).collect::<String>())
} else if trimmed.is_empty() {
"<empty body>".to_string()
} else {
trimmed
}
}
pub fn beacon_url(base: &str) -> String {
format!("{}/{}", base.trim_end_matches('/'), BEACON_KEY)
}
pub async fn probe(base: &str, expected: &PublishBeacon) -> ServedState {
let url = beacon_url(base);
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
{
Ok(c) => c,
Err(e) => {
return ServedState::Unreachable {
error: e.to_string(),
}
}
};
let resp = client
.get(&url)
.header("Cache-Control", "no-cache")
.header("Pragma", "no-cache")
.send()
.await;
match resp {
Err(e) => ServedState::Unreachable {
error: e.to_string(),
},
Ok(r) => {
let status = r.status().as_u16();
match r.bytes().await {
Ok(body) => classify(status, &body, expected),
Err(e) => ServedState::Unreachable {
error: e.to_string(),
},
}
}
}
}
pub async fn probe_with_retry(
base: &str,
expected: &PublishBeacon,
attempts: u32,
delay: Duration,
) -> ServedState {
let mut last = ServedState::Unreachable {
error: "no attempts made".to_string(),
};
for attempt in 0..attempts.max(1) {
if attempt > 0 {
tokio::time::sleep(delay).await;
}
last = probe(base, expected).await;
if last.is_match() {
return last;
}
tracing::debug!(
url = %beacon_url(base),
attempt = attempt + 1,
state = %last.summary(),
"publish beacon not yet served"
);
}
last
}
pub fn declared_front_door(workspace_root: &Path, zone: &str) -> Option<(String, FrontDoor)> {
let dir = workspace_root.join(".yah").join("domains");
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
if let Ok(dom) = DomainConfig::load(&path) {
if dom.domain.eq_ignore_ascii_case(zone) {
return Some((dom.name.clone(), dom.front_door));
}
}
}
None
}
pub fn front_door_probe_url(zone: &str, declared: Option<&FrontDoor>) -> Option<String> {
match declared {
Some(FrontDoor::BucketDirect) => None,
_ => Some(format!("https://{zone}")),
}
}
pub fn serving_failure(
zone: &str,
front_door_url: &str,
front_door_state: &ServedState,
origin_url: &str,
origin_state: &ServedState,
expected: &PublishBeacon,
declared: Option<(String, FrontDoor)>,
) -> anyhow::Error {
let declared_line = match declared {
Some((name, fd)) => format!("{} (.yah/domains/{}.toml)", fd.as_str(), name),
None => format!("none — no .yah/domains manifest claims {zone}"),
};
let origin_beacon = beacon_url(origin_url);
let front_door_beacon = beacon_url(front_door_url);
anyhow::anyhow!(
"published bytes are not being served at {zone}\n\
\n\
\x20 published : digest {} at {} ({} files under {})\n\
\x20 declared door : {}\n\
\x20 origin : {} -> {}\n\
\x20 front door : {} -> {}\n\
\n\
The publish itself may well have succeeded — what failed is that the \
public front door for this zone is serving something other than the \
prefix this apply wrote to. This exact shape (green apply, HTTP 200, \
a months-old page) froze yah.dev twice; it is a hard error so it \
cannot do so a third time.\n\
\n\
To tell the causes apart:\n\
\x20 scripts/cf-inspect-zone.sh {zone} # what actually terminates the apex\n\
\x20 scripts/cf-apex-mode.sh status # worker / grey / orange\n\
\x20 curl -sI {} # is the object even in the bucket\n\
\n\
If a front-door migration is deliberately in flight, set \
`verify_serving = false` in the mirror's {} block and \
name the ticket in a comment — do not delete this check.",
short(&expected.digest),
expected.published_label(),
expected.files,
expected.prefix,
declared_line,
origin_beacon,
origin_state.summary(),
front_door_beacon,
front_door_state.summary(),
origin_beacon,
expected.verify_serving_slot(),
)
}
pub fn bundle_beacon(manifest: &BundleManifest) -> PublishBeacon {
let contents: BTreeMap<String, String> = manifest
.content
.iter()
.filter(|(path, _)| path.as_str() != BUNDLE_BEACON_PATH)
.map(|(path, hash)| (path.clone(), hash.as_str().to_string()))
.collect();
PublishBeacon::for_bundle(&manifest.name, &contents)
}
pub fn stamp_bundle(
bundle_dir: &Path,
manifest: &mut BundleManifest,
) -> anyhow::Result<PublishBeacon> {
let beacon = bundle_beacon(manifest);
manifest.content.remove(BUNDLE_BEACON_PATH);
let bytes = serde_json::to_vec(&beacon).context("serializing bundle publish beacon")?;
let out = bundle_dir.join(BUNDLE_BEACON_PATH);
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&out, &bytes).with_context(|| format!("writing {}", out.display()))?;
manifest
.content
.insert(BUNDLE_BEACON_PATH.to_string(), BundleHash::of(&bytes));
let toml = manifest
.to_toml_string()
.context("re-serializing bundle manifest after stamping the publish beacon")?;
let manifest_path = bundle_dir.join("manifest.toml");
std::fs::write(&manifest_path, toml)
.with_context(|| format!("rewriting {}", manifest_path.display()))?;
Ok(beacon)
}
#[derive(Debug, Clone)]
pub struct ServingVerdict {
pub zone: String,
pub front_door_url: String,
pub front_door: ServedState,
pub origin_url: String,
pub origin: ServedState,
pub declared: Option<(String, FrontDoor)>,
}
impl ServingVerdict {
pub fn is_ok(&self) -> bool {
self.front_door.is_match() && self.origin.is_match()
}
pub fn into_error(self, expected: &PublishBeacon) -> anyhow::Error {
serving_failure(
&self.zone,
&self.front_door_url,
&self.front_door,
&self.origin_url,
&self.origin,
expected,
self.declared,
)
}
}
pub const EDGE_PROBE_ATTEMPTS: u32 = 4;
pub const EDGE_PROBE_DELAY: Duration = Duration::from_secs(5);
pub const NODE_PROBE_ATTEMPTS: u32 = 20;
pub const NODE_PROBE_DELAY: Duration = Duration::from_secs(6);
pub fn probe_urls(
zone: &str,
origin: Option<&str>,
declared: Option<&FrontDoor>,
) -> (String, String) {
let door = front_door_probe_url(zone, declared);
let origin_url = match (origin, &door) {
(Some(o), _) => o.to_string(),
(None, Some(d)) => d.clone(),
(None, None) => format!("https://{zone}"),
};
let front_door_url = door.unwrap_or_else(|| origin_url.clone());
(origin_url, front_door_url)
}
pub async fn check_serving(
workspace_root: &Path,
zone: &str,
origin: Option<&str>,
expected: &PublishBeacon,
attempts: u32,
delay: Duration,
) -> ServingVerdict {
let declared = declared_front_door(workspace_root, zone);
let (origin_url, front_door_url) =
probe_urls(zone, origin, declared.as_ref().map(|(_, fd)| fd));
let origin_state = probe_with_retry(&origin_url, expected, attempts, delay).await;
let front_door_state = if front_door_url == origin_url {
origin_state.clone()
} else {
probe_with_retry(&front_door_url, expected, attempts, delay).await
};
ServingVerdict {
zone: zone.to_string(),
front_door_url,
front_door: front_door_state,
origin_url,
origin: origin_state,
declared,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn beacon() -> PublishBeacon {
let mut c = BTreeMap::new();
c.insert(
"yah-marketing/cloud/index.html".to_string(),
"aa".repeat(32),
);
c.insert(
"yah-marketing/cloud/releases.html".to_string(),
"bb".repeat(32),
);
PublishBeacon::new("yah-marketing/cloud", &c)
}
#[test]
fn digest_is_order_independent_and_content_sensitive() {
let mut a = BTreeMap::new();
a.insert("b.html".to_string(), "2".to_string());
a.insert("a.html".to_string(), "1".to_string());
let mut b = BTreeMap::new();
b.insert("a.html".to_string(), "1".to_string());
b.insert("b.html".to_string(), "2".to_string());
assert_eq!(digest_of(&a), digest_of(&b));
let mut changed = b.clone();
changed.insert("a.html".to_string(), "1x".to_string());
assert_ne!(digest_of(&b), digest_of(&changed));
let mut renamed = b.clone();
renamed.remove("a.html");
renamed.insert("a2.html".to_string(), "1".to_string());
assert_ne!(digest_of(&b), digest_of(&renamed));
}
#[test]
fn digest_separates_key_from_hash() {
let mut a = BTreeMap::new();
a.insert("ab".to_string(), "c".to_string());
let mut b = BTreeMap::new();
b.insert("a".to_string(), "bc".to_string());
assert_ne!(digest_of(&a), digest_of(&b));
}
#[test]
fn matching_beacon_classifies_as_match() {
let expected = beacon();
let body = serde_json::to_vec(&expected).unwrap();
assert_eq!(classify(200, &body, &expected), ServedState::Match);
}
#[test]
fn older_beacon_classifies_as_stale_and_names_its_date() {
let expected = beacon();
let old = PublishBeacon {
prefix: expected.prefix.clone(),
published_at: Some("2026-07-15T09:00:00Z".to_string()),
digest: "cc".repeat(32),
files: 9,
};
let body = serde_json::to_vec(&old).unwrap();
let state = classify(200, &body, &expected);
match &state {
ServedState::Stale { served } => assert_eq!(served.published_at, old.published_at),
other => panic!("expected Stale, got {other:?}"),
}
assert!(
state.summary().contains("2026-07-15"),
"{}",
state.summary()
);
}
#[test]
fn two_hundred_with_html_is_not_a_match() {
let expected = beacon();
let state = classify(200, b"<!doctype html><title>yah</title>", &expected);
assert!(!state.is_match());
match state {
ServedState::NotABeacon { status, .. } => assert_eq!(status, 200),
other => panic!("expected NotABeacon, got {other:?}"),
}
}
#[test]
fn non_2xx_is_missing_and_does_not_inspect_the_body() {
let expected = beacon();
let body = serde_json::to_vec(&expected).unwrap();
assert_eq!(
classify(404, &body, &expected),
ServedState::Missing { status: 404 }
);
}
#[test]
fn beacon_url_is_prefix_joined_without_double_slash() {
assert_eq!(
beacon_url("https://cdn.yah.dev/yah-marketing/cloud/"),
"https://cdn.yah.dev/yah-marketing/cloud/.well-known/yah-publish.json"
);
assert_eq!(
beacon_url("https://yah.dev"),
"https://yah.dev/.well-known/yah-publish.json"
);
}
#[test]
fn beacon_key_last_segment_has_an_extension() {
let last = BEACON_KEY.rsplit('/').next().unwrap();
assert!(last.contains('.'), "BEACON_KEY last segment: {last}");
}
#[test]
fn beacon_round_trips_through_json() {
let b = beacon();
let bytes = serde_json::to_vec(&b).unwrap();
assert_eq!(serde_json::from_slice::<PublishBeacon>(&bytes).unwrap(), b);
}
#[test]
fn declared_front_door_finds_the_manifest_for_a_zone() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(".yah").join("domains");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("yah-dev.toml"),
r#"schema_version = 1
name = "yah-dev"
domain = "yah.dev"
front_door = "passway"
cdn_bucket = "yah-dev"
[[routes]]
path = "/*"
mode = "static"
component = "yah-marketing/site"
"#,
)
.unwrap();
let found = declared_front_door(tmp.path(), "yah.dev").unwrap();
assert_eq!(found.0, "yah-dev");
assert_eq!(found.1, FrontDoor::Passway);
assert!(declared_front_door(tmp.path(), "example.com").is_none());
}
#[test]
fn bucket_direct_has_no_front_door_distinct_from_its_origin() {
assert_eq!(
front_door_probe_url("cdn.yah.dev", Some(&FrontDoor::BucketDirect)),
None
);
assert_eq!(
front_door_probe_url("yah.dev", Some(&FrontDoor::Worker)),
Some("https://yah.dev".to_string())
);
assert_eq!(
front_door_probe_url("yah.dev", Some(&FrontDoor::Passway)),
Some("https://yah.dev".to_string())
);
assert_eq!(
front_door_probe_url("yah.dev", None),
Some("https://yah.dev".to_string())
);
}
#[test]
fn serving_failure_names_both_urls_and_the_manifest() {
let expected = beacon();
let err = serving_failure(
"yah.dev",
"https://yah.dev",
&ServedState::Missing { status: 404 },
"https://cdn.yah.dev/yah-marketing/cloud",
&ServedState::Match,
&expected,
Some(("yah-dev".to_string(), FrontDoor::Passway)),
)
.to_string();
assert!(
err.contains("https://yah.dev/.well-known/yah-publish.json"),
"{err}"
);
assert!(err.contains("cdn.yah.dev/yah-marketing/cloud"), "{err}");
assert!(err.contains(".yah/domains/yah-dev.toml"), "{err}");
assert!(err.contains("passway"), "{err}");
assert!(err.contains("verify_serving"), "{err}");
}
#[test]
fn serving_failure_names_the_block_that_holds_the_knob() {
let fail = |expected: &PublishBeacon| {
serving_failure(
"yah.dev",
"https://yah.dev",
&ServedState::Missing { status: 404 },
"https://cdn.yah.dev/yah-marketing/cloud",
&ServedState::Match,
expected,
Some(("yah-dev".to_string(), FrontDoor::Passway)),
)
.to_string()
};
let static_beacon = beacon();
assert!(!static_beacon.is_bundle_stamp());
let err = fail(&static_beacon);
assert!(err.contains("[providers.static]"), "{err}");
assert!(!err.contains("[providers.bundle]"), "{err}");
let bundle_stamp = bundle_beacon(&bundle_manifest());
assert!(bundle_stamp.is_bundle_stamp());
let err = fail(&bundle_stamp);
assert!(err.contains("[providers.bundle]"), "{err}");
assert!(!err.contains("[providers.static]"), "{err}");
}
fn bundle_manifest() -> BundleManifest {
let mut content = BTreeMap::new();
content.insert(
"app/dist/html/index.html".to_string(),
BundleHash::of(b"<html>home</html>"),
);
content.insert(
"app/dist/html/releases.html".to_string(),
BundleHash::of(b"<html>releases</html>"),
);
BundleManifest {
schema_version: yah_mesofact_bundle::SCHEMA_VERSION,
requires_contract: yah_mesofact_bundle::BUNDLE_CONTRACT_VERSION,
name: "yah-marketing".to_string(),
runtime: yah_mesofact_bundle::BundleRuntime::self_contained(),
content,
}
}
#[test]
fn the_bundle_stamp_answers_the_same_url_as_the_r2_beacon() {
assert!(
BUNDLE_BEACON_PATH.ends_with(BEACON_KEY),
"{BUNDLE_BEACON_PATH}"
);
assert_eq!(
BUNDLE_BEACON_PATH.strip_prefix("app/dist/html/"),
Some(BEACON_KEY)
);
}
#[test]
fn stamp_bundle_writes_the_beacon_and_records_it_in_the_manifest() {
let dir = tempfile::tempdir().unwrap();
let mut manifest = bundle_manifest();
let beacon = stamp_bundle(dir.path(), &mut manifest).unwrap();
let bytes = std::fs::read(dir.path().join(BUNDLE_BEACON_PATH)).unwrap();
assert_eq!(classify(200, &bytes, &beacon), ServedState::Match);
assert_eq!(
manifest.content.get(BUNDLE_BEACON_PATH),
Some(&BundleHash::of(&bytes))
);
let text = std::fs::read_to_string(dir.path().join("manifest.toml")).unwrap();
assert_eq!(BundleManifest::from_toml_str(&text).unwrap(), manifest);
assert_eq!(beacon.prefix, "bundle/yah-marketing");
assert_eq!(beacon.files, 2, "the stamp excludes itself");
assert_eq!(bundle_beacon(&manifest), beacon);
}
#[test]
fn the_stamp_digest_covers_the_rest_of_the_bundle_and_not_itself() {
let dir = tempfile::tempdir().unwrap();
let mut manifest = bundle_manifest();
let beacon = stamp_bundle(dir.path(), &mut manifest).unwrap();
let expected: BTreeMap<String, String> = bundle_manifest()
.content
.iter()
.map(|(k, h)| (k.clone(), h.as_str().to_string()))
.collect();
assert_eq!(beacon.digest, digest_of(&expected));
let mut changed = bundle_manifest();
changed.content.insert(
"app/dist/html/index.html".to_string(),
BundleHash::of(b"<html>edited</html>"),
);
let dir2 = tempfile::tempdir().unwrap();
let moved = stamp_bundle(dir2.path(), &mut changed).unwrap();
assert_ne!(beacon.digest, moved.digest);
}
#[test]
fn stamping_is_deterministic_and_idempotent() {
let a_dir = tempfile::tempdir().unwrap();
let b_dir = tempfile::tempdir().unwrap();
let mut a = bundle_manifest();
let mut b = bundle_manifest();
let beacon_a = stamp_bundle(a_dir.path(), &mut a).unwrap();
let beacon_b = stamp_bundle(b_dir.path(), &mut b).unwrap();
assert_eq!(beacon_a, beacon_b);
assert_eq!(a.digest(), b.digest());
assert!(beacon_a.published_at.is_none(), "no clock in a bundle stamp");
let digest_once = a.digest();
let beacon_again = stamp_bundle(a_dir.path(), &mut a).unwrap();
assert_eq!(beacon_again, beacon_a);
assert_eq!(a.digest(), digest_once);
}
#[test]
fn a_bundle_stamp_round_trips_and_says_it_has_no_clock() {
let mut m = bundle_manifest();
let dir = tempfile::tempdir().unwrap();
let beacon = stamp_bundle(dir.path(), &mut m).unwrap();
let bytes = serde_json::to_vec(&beacon).unwrap();
assert_eq!(
serde_json::from_slice::<PublishBeacon>(&bytes).unwrap(),
beacon
);
assert!(beacon.published_label().contains("content-addressed"));
let stale = ServedState::Stale {
served: beacon.clone(),
};
assert!(stale.summary().contains("content-addressed"), "{}", stale.summary());
}
#[test]
fn a_static_publish_probes_its_prefix_and_its_apex_separately() {
let (origin, door) = probe_urls(
"yah.dev",
Some("https://cdn.yah.dev/yah-marketing/cloud"),
Some(&FrontDoor::Passway),
);
assert_eq!(origin, "https://cdn.yah.dev/yah-marketing/cloud");
assert_eq!(door, "https://yah.dev");
assert_ne!(origin, door, "two distinct readers → two probes");
}
#[test]
fn a_bundle_publish_probes_only_its_apex() {
let (origin, door) = probe_urls("yah.dev", None, Some(&FrontDoor::Passway));
assert_eq!(origin, "https://yah.dev");
assert_eq!(door, origin);
}
#[test]
fn a_bucket_direct_zone_collapses_onto_its_origin() {
let (origin, door) = probe_urls(
"cdn.yah.dev",
Some("https://cdn.yah.dev/svc/cloud"),
Some(&FrontDoor::BucketDirect),
);
assert_eq!(origin, "https://cdn.yah.dev/svc/cloud");
assert_eq!(door, origin);
}
#[test]
fn a_contradictory_declaration_still_probes_something() {
let (origin, door) = probe_urls("yah.dev", None, Some(&FrontDoor::BucketDirect));
assert_eq!(origin, "https://yah.dev");
assert_eq!(door, origin);
}
#[test]
fn a_verdict_is_ok_only_when_every_reader_matches() {
let mk = |front: ServedState, origin: ServedState| ServingVerdict {
zone: "yah.dev".into(),
front_door_url: "https://yah.dev".into(),
front_door: front,
origin_url: "https://cdn.yah.dev/x".into(),
origin,
declared: None,
};
assert!(mk(ServedState::Match, ServedState::Match).is_ok());
assert!(!mk(ServedState::Missing { status: 404 }, ServedState::Match).is_ok());
assert!(!mk(ServedState::Match, ServedState::Missing { status: 404 }).is_ok());
let err = mk(ServedState::Missing { status: 404 }, ServedState::Match)
.into_error(&beacon())
.to_string();
assert!(err.contains("https://yah.dev/.well-known/yah-publish.json"), "{err}");
}
#[test]
fn serving_failure_says_so_when_no_manifest_claims_the_zone() {
let err = serving_failure(
"example.com",
"https://example.com",
&ServedState::Missing { status: 404 },
"https://cdn.example.com/svc/cloud",
&ServedState::Match,
&beacon(),
None,
)
.to_string();
assert!(
err.contains("no .yah/domains manifest claims example.com"),
"{err}"
);
}
}