Skip to main content

presolve_cli/
node_deployment.rs

1//! Node release inventory projected from compiler-published file routes.
2//!
3//! This adapter consumes only the immutable route manifest and the compiler's
4//! loader/server-action handoffs. It can host compiler-proven static routes,
5//! while routes with a server handoff remain explicitly Node-required until a
6//! capability-specific executor is published.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::fs;
10use std::path::Path;
11
12use presolve_compiler::{ApplicationPublicationArtifactV1, FileRoutePublicationManifestV1};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest as _, Sha256};
15
16pub const NODE_DEPLOYMENT_SCHEMA_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct NodeDeploymentOptionsV1 {
20    pub application_name: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct NodeDeploymentErrorV1 {
25    pub code: &'static str,
26    pub message: String,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct NodeDeploymentPlanV1 {
32    pub schema_version: u32,
33    pub provider: String,
34    pub application_name: String,
35    pub release_id: String,
36    pub routes: Vec<NodeDeploymentRouteV1>,
37    pub artifacts: Vec<NodeDeploymentArtifactV1>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct NodeDeploymentRouteV1 {
43    pub path: String,
44    pub artifact_root: String,
45    /// `static` is compiler-proven exportable. `node` requires a future
46    /// capability-specific loader or server-action executor.
47    pub execution: String,
48    pub loader_count: usize,
49    pub server_action_count: usize,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "camelCase")]
54pub struct NodeDeploymentArtifactV1 {
55    pub path: String,
56    pub digest: String,
57}
58
59#[derive(Debug, Deserialize)]
60#[serde(rename_all = "camelCase")]
61struct LoaderPlanV1 {
62    #[serde(alias = "schema_version")]
63    schema_version: u32,
64    routes: Vec<LoaderPlanRouteV1>,
65}
66
67#[derive(Debug, Deserialize)]
68struct LoaderPlanRouteV1 {
69    path: String,
70    loaders: Vec<serde_json::Value>,
71}
72
73#[derive(Debug, Deserialize)]
74#[serde(rename_all = "camelCase")]
75struct ServerActionPlanV1 {
76    #[serde(alias = "schema_version")]
77    schema_version: u32,
78    routes: Vec<ServerActionPlanRouteV1>,
79}
80
81#[derive(Debug, Deserialize)]
82struct ServerActionPlanRouteV1 {
83    path: String,
84    actions: Vec<serde_json::Value>,
85}
86
87/// Produces a Node inventory without interpreting application source.
88///
89/// A route is statically exportable only when both compiler-issued handoff
90/// plans contain no work for that exact route. The adapter rejects missing,
91/// duplicate, or foreign handoff routes instead of guessing their meaning.
92pub fn build_node_deployment_plan_v1(
93    manifest: &FileRoutePublicationManifestV1,
94    loader_plan_source: &str,
95    server_action_plan_source: &str,
96    options: &NodeDeploymentOptionsV1,
97) -> Result<NodeDeploymentPlanV1, NodeDeploymentErrorV1> {
98    if !is_application_name(&options.application_name) {
99        return Err(NodeDeploymentErrorV1 {
100            code: "PSNODE1001_APPLICATION_NAME_INVALID",
101            message: options.application_name.clone(),
102        });
103    }
104    if manifest.schema_version != 1
105        || manifest.compiler_contract != "presolve-file-route-publication:1"
106    {
107        return Err(NodeDeploymentErrorV1 {
108            code: "PSNODE1002_ROUTE_MANIFEST_UNSUPPORTED",
109            message: format!(
110                "schema {} contract {}",
111                manifest.schema_version, manifest.compiler_contract
112            ),
113        });
114    }
115    let loaders: LoaderPlanV1 =
116        serde_json::from_str(loader_plan_source).map_err(|error| NodeDeploymentErrorV1 {
117            code: "PSNODE1003_LOADER_HANDOFF_INVALID",
118            message: error.to_string(),
119        })?;
120    let actions: ServerActionPlanV1 =
121        serde_json::from_str(server_action_plan_source).map_err(|error| NodeDeploymentErrorV1 {
122            code: "PSNODE1004_SERVER_ACTION_HANDOFF_INVALID",
123            message: error.to_string(),
124        })?;
125    if loaders.schema_version != 1 || actions.schema_version != 1 {
126        return Err(NodeDeploymentErrorV1 {
127            code: "PSNODE1005_HANDOFF_SCHEMA_UNSUPPORTED",
128            message: format!(
129                "loader {} server-action {}",
130                loaders.schema_version, actions.schema_version
131            ),
132        });
133    }
134    let route_paths = manifest
135        .routes
136        .iter()
137        .map(|route| route.path.clone())
138        .collect::<BTreeSet<_>>();
139    if route_paths.len() != manifest.routes.len()
140        || route_paths.is_empty()
141        || manifest.routes.iter().any(|route| {
142            !is_route_path(&route.path)
143                || !is_artifact_path(&route.artifact_root)
144                || !route.artifact_root.starts_with("routes/")
145        })
146    {
147        return Err(NodeDeploymentErrorV1 {
148            code: "PSNODE1006_ROUTE_MANIFEST_INVALID",
149            message: "route manifest must contain one or more unique routes".into(),
150        });
151    }
152    let loader_counts = handoff_counts(
153        loaders
154            .routes
155            .into_iter()
156            .map(|route| (route.path, route.loaders.len())),
157        &route_paths,
158        "loader",
159    )?;
160    let action_counts = handoff_counts(
161        actions
162            .routes
163            .into_iter()
164            .map(|route| (route.path, route.actions.len())),
165        &route_paths,
166        "server-action",
167    )?;
168    let mut routes = manifest
169        .routes
170        .iter()
171        .map(|route| {
172            let loader_count = loader_counts[&route.path];
173            let server_action_count = action_counts[&route.path];
174            NodeDeploymentRouteV1 {
175                path: route.path.clone(),
176                artifact_root: route.artifact_root.clone(),
177                execution: if loader_count == 0 && server_action_count == 0 {
178                    "static".into()
179                } else {
180                    "node".into()
181                },
182                loader_count,
183                server_action_count,
184            }
185        })
186        .collect::<Vec<_>>();
187    routes.sort_by(|left, right| left.path.cmp(&right.path));
188    let mut artifacts = manifest
189        .artifacts
190        .iter()
191        .map(node_artifact)
192        .collect::<Result<Vec<_>, _>>()?;
193    artifacts.sort_by(|left, right| left.path.cmp(&right.path));
194    if artifacts
195        .windows(2)
196        .any(|pair| pair[0].path == pair[1].path)
197    {
198        return Err(NodeDeploymentErrorV1 {
199            code: "PSNODE1010_ARTIFACT_PROJECTION_INVALID",
200            message: "compiler artifact inventory contains duplicate paths".into(),
201        });
202    }
203    let release_input = routes
204        .iter()
205        .map(|route| {
206            format!(
207                "{}:{}:{}:{}:{}\n",
208                route.path,
209                route.artifact_root,
210                route.execution,
211                route.loader_count,
212                route.server_action_count
213            )
214        })
215        .chain(
216            artifacts
217                .iter()
218                .map(|artifact| format!("{}:{}\n", artifact.path, artifact.digest)),
219        )
220        .collect::<String>();
221    Ok(NodeDeploymentPlanV1 {
222        schema_version: NODE_DEPLOYMENT_SCHEMA_VERSION,
223        provider: "node".into(),
224        application_name: options.application_name.clone(),
225        release_id: format!("sha256:{:x}", Sha256::digest(release_input)),
226        routes,
227        artifacts,
228    })
229}
230
231/// Verifies the output directory against the compiler-authored release inventory.
232pub fn validate_node_deployment_artifacts_v1(
233    output_root: &Path,
234    plan: &NodeDeploymentPlanV1,
235) -> Result<(), NodeDeploymentErrorV1> {
236    for artifact in &plan.artifacts {
237        let path = output_root.join(&artifact.path);
238        let bytes = fs::read(&path).map_err(|error| NodeDeploymentErrorV1 {
239            code: "PSNODE1008_ARTIFACT_READ_FAILED",
240            message: format!("{}: {error}", path.display()),
241        })?;
242        if format!("sha256:{:x}", Sha256::digest(&bytes)) != artifact.digest {
243            return Err(NodeDeploymentErrorV1 {
244                code: "PSNODE1009_ARTIFACT_INTEGRITY_MISMATCH",
245                message: artifact.path.clone(),
246            });
247        }
248    }
249    Ok(())
250}
251
252#[must_use]
253pub fn node_deployment_plan_json_v1(plan: &NodeDeploymentPlanV1) -> String {
254    serde_json::to_string_pretty(plan).expect("Node deployment plan serializes") + "\n"
255}
256
257/// Emits a small Node static host that consumes the release plan verbatim.
258/// Server-bound routes fail loudly rather than pretending that loaders or
259/// server actions were executed.
260#[must_use]
261pub fn node_static_host_module_v1(plan: &NodeDeploymentPlanV1) -> String {
262    let routes = serde_json::to_string(&plan.routes).expect("Node routes serialize");
263    format!(
264        "// Generated by Presolve. Do not edit.\n\
265import {{ createServer }} from 'node:http';\n\
266import {{ readFile }} from 'node:fs/promises';\n\
267import {{ resolve }} from 'node:path';\n\
268import {{ fileURLToPath }} from 'node:url';\n\
269const routes = {routes};\n\
270const outputRoot = fileURLToPath(new URL('../../dist/', import.meta.url));\n\
271const port = Number(process.env.PORT || '3000');\n\
272function segments(pathname) {{ return pathname.split('/').filter(Boolean); }}\n\
273function score(parts) {{ return [parts.filter((part) => !part.startsWith(':')).length, parts.length]; }}\n\
274function routeFor(requested) {{\n\
275  let selected;\n\
276  for (const route of routes) {{\n\
277    const parts = segments(route.path);\n\
278    if (requested.length < parts.length) continue;\n\
279    if (!parts.every((part, index) => part.startsWith(':') ? requested[index]?.length > 0 : part === requested[index])) continue;\n\
280    const candidate = score(parts);\n\
281    if (!selected || candidate[0] > selected.score[0] || (candidate[0] === selected.score[0] && candidate[1] > selected.score[1])) selected = {{ route, parts, score: candidate }};\n\
282  }}\n\
283  return selected;\n\
284}}\n\
285function safeSegments(values) {{ return values.every((value) => value && value !== '.' && value !== '..' && !value.includes('\\\\')); }}\n\
286createServer(async (request, response) => {{\n\
287  if (request.method !== 'GET' && request.method !== 'HEAD') {{ response.writeHead(405, {{ Allow: 'GET, HEAD' }}); response.end(); return; }}\n\
288  const url = new URL(request.url, 'http://presolve.local');\n\
289  const requested = segments(url.pathname);\n\
290  const selected = routeFor(requested);\n\
291  if (!selected) {{\n\
292    if (!safeSegments(requested) || requested.length === 0) {{ response.writeHead(404); response.end('Not Found\\n'); return; }}\n\
293    try {{ const bytes = await readFile(resolve(outputRoot, requested.join('/'))); response.writeHead(200); response.end(request.method === 'HEAD' ? undefined : bytes); return; }}\n\
294    catch {{ response.writeHead(404); response.end('Not Found\\n'); return; }}\n\
295  }}\n\
296  if (selected.route.execution !== 'static') {{ response.writeHead(501); response.end('Presolve Node executor required for this route\\n'); return; }}\n\
297  const suffix = requested.slice(selected.parts.length);\n\
298  if (!safeSegments(suffix)) {{ response.writeHead(404); response.end('Not Found\\n'); return; }}\n\
299  const relative = suffix.length === 0 ? `${{selected.route.artifactRoot}}/index.html` : `${{selected.route.artifactRoot}}/${{suffix.join('/')}}`;\n\
300  try {{ const bytes = await readFile(resolve(outputRoot, relative)); response.writeHead(200); response.end(request.method === 'HEAD' ? undefined : bytes); }}\n\
301  catch {{\n\
302    if (!safeSegments(requested) || requested.length === 0) {{ response.writeHead(404); response.end('Not Found\\n'); return; }}\n\
303    try {{ const bytes = await readFile(resolve(outputRoot, requested.join('/'))); response.writeHead(200); response.end(request.method === 'HEAD' ? undefined : bytes); }}\n\
304    catch {{ response.writeHead(404); response.end('Not Found\\n'); }}\n\
305  }}\n\
306}}).listen(port, '0.0.0.0', () => console.log(`Presolve Node release listening on ${{port}}`));\n"
307    )
308}
309
310fn handoff_counts(
311    values: impl IntoIterator<Item = (String, usize)>,
312    expected_paths: &BTreeSet<String>,
313    kind: &str,
314) -> Result<BTreeMap<String, usize>, NodeDeploymentErrorV1> {
315    let values = values.into_iter().collect::<Vec<_>>();
316    let counts = values.iter().cloned().collect::<BTreeMap<_, _>>();
317    if values.len() != counts.len()
318        || counts.len() != expected_paths.len()
319        || counts.keys().collect::<BTreeSet<_>>() != expected_paths.iter().collect::<BTreeSet<_>>()
320    {
321        return Err(NodeDeploymentErrorV1 {
322            code: "PSNODE1007_HANDOFF_ROUTE_SET_MISMATCH",
323            message: format!(
324                "{kind} handoff routes do not exactly match the compiler route manifest"
325            ),
326        });
327    }
328    Ok(counts)
329}
330
331fn node_artifact(
332    artifact: &ApplicationPublicationArtifactV1,
333) -> Result<NodeDeploymentArtifactV1, NodeDeploymentErrorV1> {
334    if !is_artifact_path(&artifact.path) || !artifact.digest.starts_with("sha256:") {
335        return Err(NodeDeploymentErrorV1 {
336            code: "PSNODE1010_ARTIFACT_PROJECTION_INVALID",
337            message: artifact.path.clone(),
338        });
339    }
340    Ok(NodeDeploymentArtifactV1 {
341        path: artifact.path.clone(),
342        digest: artifact.digest.clone(),
343    })
344}
345
346fn is_application_name(value: &str) -> bool {
347    !value.is_empty()
348        && value.len() <= 63
349        && value.chars().all(|character| {
350            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
351        })
352}
353
354fn is_artifact_path(value: &str) -> bool {
355    !value.is_empty()
356        && !value.starts_with('/')
357        && value.split('/').all(|segment| {
358            !segment.is_empty() && segment != "." && segment != ".." && !segment.contains('\\')
359        })
360}
361
362fn is_route_path(value: &str) -> bool {
363    value.starts_with('/') && !value.contains('?') && !value.contains('#') && !value.contains('\\')
364}
365
366#[cfg(test)]
367mod tests {
368    use presolve_compiler::{
369        ApplicationPublicationArtifactV1, FileRoutePublicationManifestV1,
370        FileRoutePublicationRouteV1,
371    };
372
373    use super::{
374        build_node_deployment_plan_v1, node_static_host_module_v1, NodeDeploymentOptionsV1,
375    };
376
377    fn manifest() -> FileRoutePublicationManifestV1 {
378        FileRoutePublicationManifestV1 {
379            schema_version: 1,
380            compiler_contract: "presolve-file-route-publication:1".into(),
381            profile: "production".into(),
382            routes: vec![
383                FileRoutePublicationRouteV1 {
384                    path: "/".into(),
385                    entry_component_id: "component:home".into(),
386                    artifact_root: "routes/root".into(),
387                    layout_component_ids: Vec::new(),
388                },
389                FileRoutePublicationRouteV1 {
390                    path: "/posts/:slug".into(),
391                    entry_component_id: "component:post".into(),
392                    artifact_root: "routes/segment-posts/parameter-slug".into(),
393                    layout_component_ids: Vec::new(),
394                },
395            ],
396            artifacts: vec![ApplicationPublicationArtifactV1 {
397                path: "routes/root/index.html".into(),
398                digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
399                    .into(),
400            }],
401        }
402    }
403
404    #[test]
405    fn marks_only_compiler_proven_handoff_free_routes_static() {
406        let plan = build_node_deployment_plan_v1(
407            &manifest(),
408            r#"{"schemaVersion":1,"routes":[{"path":"/","loaders":[]},{"path":"/posts/:slug","loaders":[{}]}]}"#,
409            r#"{"schemaVersion":1,"routes":[{"path":"/","actions":[]},{"path":"/posts/:slug","actions":[]}]}"#,
410            &NodeDeploymentOptionsV1 {
411                application_name: "presolve-docs".into(),
412            },
413        )
414        .unwrap();
415        assert_eq!(plan.routes[0].execution, "static");
416        assert_eq!(plan.routes[1].execution, "node");
417        let host = node_static_host_module_v1(&plan);
418        assert!(host.contains("executor required"));
419        assert!(host.contains("routes/segment-posts/parameter-slug"));
420    }
421
422    #[test]
423    fn rejects_handoff_route_sets_that_do_not_equal_the_manifest() {
424        let error = build_node_deployment_plan_v1(
425            &manifest(),
426            r#"{"schemaVersion":1,"routes":[{"path":"/","loaders":[]}]}"#,
427            r#"{"schemaVersion":1,"routes":[{"path":"/","actions":[]},{"path":"/posts/:slug","actions":[]}]}"#,
428            &NodeDeploymentOptionsV1 {
429                application_name: "presolve-docs".into(),
430            },
431        )
432        .unwrap_err();
433        assert_eq!(error.code, "PSNODE1007_HANDOFF_ROUTE_SET_MISMATCH");
434    }
435}