Skip to main content

mesofact_core/
manifest.rs

1//! Manifest types — the single document the build emits and the proxy boots
2//! from. Mirrors `packages/mesofact-runtime/src/manifest.ts`. Schema spec lives
3//! in `.yah/docs/architecture/mesofact.md` §"Manifest schema".
4
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8/// Current manifest schema version. Major bumps force a proxy restart.
9pub const MANIFEST_VERSION: &str = "1";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum RouteMode {
14    Static,
15    Ssr,
16    Spa,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum Requires {
22    User,
23    Project,
24    Region,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct CachePolicy {
29    pub ttl: u64,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub swr: Option<u64>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub negative_ttl: Option<u64>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub vary: Option<Vec<String>>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Hydration {
40    pub script: String,
41    pub code_split: Vec<String>,
42}
43
44/// SSR placement as carried in the manifest — the build resolves `"auto"`
45/// to a concrete value before emission, so consumers never see `auto`.
46/// Mirrors `ResolvedPlacement` in `packages/mesofact-runtime/src/manifest.ts`.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum ResolvedPlacement {
50    Host,
51    Edge,
52}
53
54/// W181 resilience axis (v1: retry + timeout). Applied by the always-up
55/// edge (CF Worker in prod, the mesofact-dev proxy in dev) around the SSR
56/// origin hop. `queue` is type-reserved for v2; `defineRoutes` rejects it
57/// today, so a well-formed manifest never carries it — the slot exists so
58/// v1 binaries keep deserializing when v2 manifests appear.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct RetryPolicy {
61    /// Total attempts including the first; 1 = no retry.
62    pub attempts: u32,
63    /// Gap before attempt i+1; `len() == attempts - 1`.
64    pub backoff_ms: Vec<u64>,
65    /// `"connection"` (default) | `"5xx"` | `"any"`.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub retry_on: Option<String>,
68    /// Total wall-clock cap across request + retries + backoffs.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub budget_ms: Option<u64>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct QueuePolicy {
75    pub queue: String,
76    pub ack: String,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub max_delay_ms: Option<u64>,
79}
80
81/// Default per-attempt timeout when `resilience.timeout_ms` is omitted.
82pub const DEFAULT_RESILIENCE_TIMEOUT_MS: u64 = 30_000;
83
84#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
85pub struct ResiliencePolicy {
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub retry: Option<RetryPolicy>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub queue: Option<QueuePolicy>,
90    /// Per-attempt request timeout; default [`DEFAULT_RESILIENCE_TIMEOUT_MS`].
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub timeout_ms: Option<u64>,
93}
94
95/// Mode 1 only. Three shapes the publisher runs at build time, plus one
96/// that defers past the build entirely:
97///   - `Literal` — explicit list of param maps
98///   - `SourceDerived` — a registered source adapter (R2 BlobSource) walked
99///     via async load
100///   - `FromData` — a local JSON file declared on the same route's
101///     `data_inputs`, walked synchronously via `items_key` (dotted path)
102///   - `Deferred` — params are minted after the build (publish time); the
103///     build emits the server bundle + manifest entry and prerenders
104///     nothing. Instances are produced exclusively through the render-only
105///     entrypoint, and serving resolves them per instance (the route is
106///     *instance-addressed* — W225 §3a publish-once / parent-camp W270 §2).
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(untagged)]
109pub enum Prerender {
110    Literal {
111        params: Vec<BTreeMap<String, String>>,
112    },
113    SourceDerived {
114        from: String,
115        query: String,
116        param: String,
117    },
118    FromData {
119        from_data: String,
120        items_key: String,
121        param: String,
122    },
123    Deferred {
124        /// Always `true` in a well-formed config (`{ deferred: true }`);
125        /// `false` is rejected at validation.
126        deferred: bool,
127    },
128}
129
130impl Prerender {
131    /// Instance-addressed routes render after the build, one instance per
132    /// minted param set; serving resolves them through a pointer store
133    /// rather than the build-time HTML set.
134    pub fn is_deferred(&self) -> bool {
135        matches!(self, Prerender::Deferred { deferred: true })
136    }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct Route {
141    pub route: String,
142    pub mode: RouteMode,
143    pub render_entrypoint: String,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub requires: Option<Vec<Requires>>,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub source_reads: Option<Vec<String>>,
148    /// Build-time data artifact paths (relative to project root). Present
149    /// when the route declared `data_inputs`; the reconciler uses it to map
150    /// file changes to route rebuilds.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub data_inputs: Option<Vec<String>>,
153    pub cache_policy: CachePolicy,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub concurrency: Option<u32>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub hydration: Option<Hydration>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub prerender: Option<Prerender>,
160    /// SSR routes only; never `auto` (resolved at build time per W173).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub placement: Option<ResolvedPlacement>,
163    /// SSR routes only (W181); see [`ResiliencePolicy`].
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub resilience: Option<ResiliencePolicy>,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct StaticAsset {
170    pub key: String,
171    pub content_hash: String,
172    pub content_type: String,
173    pub immutable: bool,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
177pub struct ErrorRoutes {
178    #[serde(default, skip_serializing_if = "Option::is_none", rename = "404")]
179    pub not_found: Option<String>,
180    #[serde(default, skip_serializing_if = "Option::is_none", rename = "5xx")]
181    pub server_error: Option<String>,
182}
183
184/// One declared Mode 2 hook (W311 §2 / R756-F6) — an engine-addressed
185/// endpoint callback rather than a route. A struct rather than a bare path
186/// string so the per-hook options W311 left open (chiefly Open Decision 3,
187/// "may a hook be async") land without a schema break.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct Hook {
190    /// Bundled module, `dist/server/hooks/<name>.js`. Same shape and
191    /// resolution rule as a route's `render_entrypoint`.
192    pub entrypoint: String,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct Manifest {
197    pub version: String,
198    pub build_id: String,
199    pub routes: Vec<Route>,
200    #[serde(default)]
201    pub static_assets: Vec<StaticAsset>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub error_routes: Option<ErrorRoutes>,
204    /// Declared Mode 2 hooks, name → bundled module (R756-F6). Absent when
205    /// the workload declares none. Host-only: the edge Worker never invokes a
206    /// hook, since Mode 2's premise is that the Rust host owns the HTTP
207    /// around it. Carried here so a manifest round-trips through the
208    /// publisher without losing the block.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub hooks: Option<BTreeMap<String, Hook>>,
211    /// Derived from every `mode:"ssr"` route per W173 § "SSR_PREFIXES
212    /// derivation rule". Segment-aware match at the consumer:
213    /// `path == p || path.starts_with(&format!("{p}/"))`. Absent when the
214    /// workload has no SSR routes.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub ssr_prefixes: Option<Vec<String>>,
217}