Skip to main content

thread_engine/
resolver.rs

1//! Locator resolution — the Thread's "DNS", network-first with a local fallback.
2//!
3//! Given a `thread://…` Locator, resolve it to a world's manifest text (+ where
4//! its assets live). Tries the main agent's live resolver
5//! (`GET <base>/thread/resolve?loc=…` → `{ manifestUrl, assetBase, … }`, then
6//! fetches the manifest), and falls back to a **local** `world.json`
7//! (`<worlds_root>/<path>/world.json`) when the network is unavailable. So the
8//! browser works today against local worlds and cuts over to the live network
9//! the moment the resolver is deployed — no code change.
10
11use std::path::{Path, PathBuf};
12use std::sync::OnceLock;
13
14use infinite_manifest::Locator;
15use serde::Deserialize;
16
17use crate::home::is_home;
18
19/// A resolved world: its manifest text + where its assets resolve.
20pub struct TravelResult {
21    pub manifest_text: String,
22    /// Local base for relative asset URIs (a local `world.json` dir), or the cache
23    /// dir for a hosted world.
24    pub asset_base: PathBuf,
25    /// Base URL for relative asset URIs when the world is hosted (its directory URL),
26    /// so `meshes/x.glb` fetches from the right place. `None` for a local world.
27    pub asset_base_url: Option<String>,
28}
29
30/// The main agent's resolver response (only the fields the browser consumes).
31#[derive(Deserialize)]
32struct ResolveResponse {
33    #[serde(rename = "manifestUrl")]
34    manifest_url: String,
35    #[serde(rename = "assetBase", default)]
36    _asset_base: Option<String>,
37}
38
39/// A browser-registered **world synthesizer**: given a Locator no host serves
40/// and no local file matches, it may build the world on the spot (the spatial
41/// encyclopedia generates halls the moment someone steps toward them). Runs
42/// on the background travel thread; return `None` to decline.
43type Synthesizer = Box<dyn Fn(&str) -> Option<TravelResult> + Send + Sync>;
44static SYNTHESIZER: OnceLock<Synthesizer> = OnceLock::new();
45
46/// Register the session's synthesizer (first registration wins).
47pub fn register_synthesizer(f: impl Fn(&str) -> Option<TravelResult> + Send + Sync + 'static) {
48    let _ = SYNTHESIZER.set(Box::new(f));
49}
50
51/// Resolve a Locator to a world (blocking — call on a background thread), in
52/// order of decreasing coupling:
53///   1. the configured **resolver/registry** (Pixygon worlds + value-adds),
54///   2. the host's own **`.well-known/thread`** — decentralized, zero-registry:
55///      this is what lets *anyone* host a world on their domain with zero contact,
56///   3. a **local** `world.json` (dev / offline authoring).
57pub fn fetch_world(
58    resolver_base: &str,
59    worlds_root: &Path,
60    locator: &str,
61) -> Result<TravelResult, String> {
62    let cache = || std::env::temp_dir().join("infinite_thread_assets");
63
64    // 0. The traveler's own home — `thread://home` is the Thread's `localhost`:
65    //    always the local home file, never the network. Resolving it here makes
66    //    home first-class everywhere a Locator goes (address bar, history,
67    //    portal destinations) without any host ever seeing it.
68    if is_home(locator) {
69        let path = crate::home::world_path(&crate::home::default_dir());
70        let text = std::fs::read_to_string(&path)
71            .map_err(|e| format!("no home world at {} ({e})", path.display()))?;
72        return Ok(TravelResult {
73            manifest_text: text,
74            asset_base: path.parent().unwrap_or(Path::new(".")).to_path_buf(),
75            asset_base_url: None,
76        });
77    }
78
79    // Dogfood mode: INFINITE_NO_REGISTRY=1 walks the Thread exactly as a
80    // spec-only third-party browser would — `.well-known` and nothing else
81    // (no registry, no local dev worlds, no synthesizer). If the constellation
82    // rots behind the resolver, THIS run is how we notice before an early
83    // adopter does.
84    let spec_only = std::env::var("INFINITE_NO_REGISTRY").is_ok_and(|v| !v.is_empty() && v != "0");
85    if spec_only {
86        return match resolve_well_known(locator) {
87            Ok((text, base_url)) => {
88                tracing::info!("resolved '{locator}' via .well-known (spec-only mode)");
89                Ok(TravelResult {
90                    manifest_text: text,
91                    asset_base: cache(),
92                    asset_base_url: base_url,
93                })
94            }
95            Err(e) => Err(format!(
96                "no .well-known world for {locator} ({e}) — spec-only mode"
97            )),
98        };
99    }
100
101    // 1. Registry / resolver.
102    let remote_err = match resolve_remote(resolver_base, locator) {
103        Ok((text, base_url)) => {
104            return Ok(TravelResult {
105                manifest_text: text,
106                asset_base: cache(),
107                asset_base_url: base_url,
108            })
109        }
110        Err(e) => e,
111    };
112
113    // 2. Decentralized: the host's own `.well-known/thread/…/world.json`.
114    if let Ok((text, base_url)) = resolve_well_known(locator) {
115        tracing::info!("resolved '{locator}' via .well-known (no registry)");
116        return Ok(TravelResult {
117            manifest_text: text,
118            asset_base: cache(),
119            asset_base_url: base_url,
120        });
121    }
122
123    // 3. Local dev file.
124    let path = local_path(worlds_root, locator)
125        .ok_or_else(|| format!("'{locator}' is not a Locator ({remote_err})"))?;
126    if let Ok(text) = std::fs::read_to_string(&path) {
127        tracing::info!("resolved '{locator}' locally (network: {remote_err})");
128        return Ok(TravelResult {
129            manifest_text: text,
130            asset_base: path.parent().unwrap_or(Path::new(".")).to_path_buf(),
131            asset_base_url: None,
132        });
133    }
134
135    // 4. Synthesis: a registered generator may build the world on approach.
136    if let Some(synth) = SYNTHESIZER.get() {
137        if let Some(tr) = synth(locator) {
138            tracing::info!("synthesized '{locator}' on approach");
139            return Ok(tr);
140        }
141    }
142    Err(format!("no world at {} ({remote_err})", path.display()))
143}
144
145/// Fetch the host's own manifest directly by the `.well-known` convention —
146/// no resolver, no registry, no Pixygon involvement. Returns the manifest text and
147/// the world's directory URL (so relative assets fetch from the right place).
148fn resolve_well_known(locator: &str) -> Result<(String, Option<String>), String> {
149    let loc = Locator::parse(locator).ok_or_else(|| format!("'{locator}' is not a Locator"))?;
150    let manifest_url = infinite_manifest::well_known_url(&loc.host, &loc.path);
151    match http_get_text(&manifest_url) {
152        Ok(text) => Ok((text, dir_url(&manifest_url))),
153        Err(json_err) => {
154            // A host may serve Thread markup instead of JSON — same convention,
155            // `.thread` extension (exactly like serving HTML). The navigator's
156            // `from_text` parses either form.
157            let markup_url = format!(
158                "{}world.thread",
159                manifest_url.trim_end_matches("world.json")
160            );
161            let text = http_get_text(&markup_url).map_err(|_| json_err)?;
162            Ok((text, dir_url(&markup_url)))
163        }
164    }
165}
166
167/// Hit the live resolver, then download the manifest it points at. Returns the
168/// manifest text + the asset base URL (the resolver's `assetBase`, or the manifest's
169/// own directory).
170fn resolve_remote(base: &str, locator: &str) -> Result<(String, Option<String>), String> {
171    let url = format!(
172        "{}/thread/resolve?loc={}",
173        base.trim_end_matches('/'),
174        urlencode(locator)
175    );
176    let body = http_get_text(&url)?;
177    let resolved: ResolveResponse = serde_json::from_str(&body).map_err(|e| e.to_string())?;
178    let text = http_get_text(&resolved.manifest_url)?;
179    let base_url = resolved
180        ._asset_base
181        .clone()
182        .or_else(|| dir_url(&resolved.manifest_url));
183    Ok((text, base_url))
184}
185
186/// The directory URL of a manifest URL (everything up to and including the last `/`).
187fn dir_url(manifest_url: &str) -> Option<String> {
188    manifest_url
189        .rsplit_once('/')
190        .map(|(dir, _)| format!("{dir}/"))
191}
192
193/// Map a Locator to its local `world.json` — or `world.thread` markup when only
194/// that exists (dev fallback resolver; both source forms are first-class).
195fn local_path(worlds_root: &Path, locator: &str) -> Option<PathBuf> {
196    let loc = Locator::parse(locator)?;
197    let dir = if loc.path.is_empty() {
198        loc.host
199    } else {
200        loc.path
201    };
202    let json = worlds_root.join(&dir).join("world.json");
203    if !json.exists() {
204        let thread = worlds_root.join(&dir).join("world.thread");
205        if thread.exists() {
206            return Some(thread);
207        }
208    }
209    Some(json)
210}
211
212/// Blocking HTTP GET on a throwaway runtime (reqwest is async-only here).
213fn http_get_text(url: &str) -> Result<String, String> {
214    let rt = tokio::runtime::Builder::new_current_thread()
215        .enable_all()
216        .build()
217        .map_err(|e| e.to_string())?;
218    rt.block_on(async {
219        let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
220        let status = resp.status();
221        if !status.is_success() {
222            return Err(format!("HTTP {}", status.as_u16()));
223        }
224        resp.text().await.map_err(|e| e.to_string())
225    })
226}
227
228/// Percent-encode a Locator for use as a query value.
229fn urlencode(s: &str) -> String {
230    let mut out = String::with_capacity(s.len());
231    for b in s.bytes() {
232        match b {
233            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
234                out.push(b as char)
235            }
236            _ => out.push_str(&format!("%{b:02X}")),
237        }
238    }
239    out
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn maps_locators_to_local_files() {
248        let root = Path::new("worlds");
249        assert_eq!(
250            local_path(root, "thread://market.pixygon.io/market"),
251            Some(PathBuf::from("worlds/market/world.json"))
252        );
253        assert_eq!(
254            local_path(root, "thread://archive.pixygon.io/codex-archive#entry"),
255            Some(PathBuf::from("worlds/codex-archive/world.json"))
256        );
257        assert!(local_path(root, "https://not-thread.com").is_none());
258    }
259
260    #[test]
261    fn local_resolution_falls_back_to_markup_when_only_that_exists() {
262        // Run against the reference corpus so the existence checks are honest
263        // — and skip when it isn't there, because that corpus is content that
264        // travels with the browser, not with the engine.
265        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../worlds");
266        if !root.is_dir() {
267            eprintln!("no reference corpus beside this crate — skipping");
268            return;
269        }
270        // The Meadow ships as markup only → resolves to its .thread source.
271        let meadow = local_path(&root, "thread://meadow").expect("locator parses");
272        assert!(
273            meadow.ends_with("meadow/world.thread"),
274            "got {}",
275            meadow.display()
276        );
277        // A world with a world.json keeps resolving to JSON.
278        let market = local_path(&root, "thread://market.pixygon.io/market").unwrap();
279        assert!(
280            market.ends_with("market/world.json"),
281            "got {}",
282            market.display()
283        );
284    }
285
286    #[test]
287    fn thread_home_is_recognized_in_all_its_spellings() {
288        assert!(is_home("thread://home"));
289        assert!(is_home("thread://home#entry"));
290        assert!(is_home("thread://home@1200"));
291        // A world *named* home on a real host is not the home-space.
292        assert!(!is_home("thread://example.com/home"));
293        assert!(!is_home("thread://homestead.io"));
294        assert!(!is_home("not a locator"));
295    }
296
297    #[test]
298    fn percent_encodes_locators() {
299        assert_eq!(
300            urlencode("thread://market.pixygon.io/market#entry"),
301            "thread%3A%2F%2Fmarket.pixygon.io%2Fmarket%23entry"
302        );
303    }
304}