1use 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 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
87pub 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
231pub 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#[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) {{ response.writeHead(404); response.end('Not Found\\n'); return; }}\n\
292 if (selected.route.execution !== 'static') {{ response.writeHead(501); response.end('Presolve Node executor required for this route\\n'); return; }}\n\
293 const suffix = requested.slice(selected.parts.length);\n\
294 if (!safeSegments(suffix)) {{ response.writeHead(404); response.end('Not Found\\n'); return; }}\n\
295 const relative = suffix.length === 0 ? `${{selected.route.artifactRoot}}/index.html` : `${{selected.route.artifactRoot}}/${{suffix.join('/')}}`;\n\
296 try {{ const bytes = await readFile(resolve(outputRoot, relative)); response.writeHead(200); response.end(request.method === 'HEAD' ? undefined : bytes); }}\n\
297 catch {{ response.writeHead(404); response.end('Not Found\\n'); }}\n\
298}}).listen(port, '0.0.0.0', () => console.log(`Presolve Node release listening on ${{port}}`));\n"
299 )
300}
301
302fn handoff_counts(
303 values: impl IntoIterator<Item = (String, usize)>,
304 expected_paths: &BTreeSet<String>,
305 kind: &str,
306) -> Result<BTreeMap<String, usize>, NodeDeploymentErrorV1> {
307 let values = values.into_iter().collect::<Vec<_>>();
308 let counts = values.iter().cloned().collect::<BTreeMap<_, _>>();
309 if values.len() != counts.len()
310 || counts.len() != expected_paths.len()
311 || counts.keys().collect::<BTreeSet<_>>() != expected_paths.iter().collect::<BTreeSet<_>>()
312 {
313 return Err(NodeDeploymentErrorV1 {
314 code: "PSNODE1007_HANDOFF_ROUTE_SET_MISMATCH",
315 message: format!(
316 "{kind} handoff routes do not exactly match the compiler route manifest"
317 ),
318 });
319 }
320 Ok(counts)
321}
322
323fn node_artifact(
324 artifact: &ApplicationPublicationArtifactV1,
325) -> Result<NodeDeploymentArtifactV1, NodeDeploymentErrorV1> {
326 if !is_artifact_path(&artifact.path) || !artifact.digest.starts_with("sha256:") {
327 return Err(NodeDeploymentErrorV1 {
328 code: "PSNODE1010_ARTIFACT_PROJECTION_INVALID",
329 message: artifact.path.clone(),
330 });
331 }
332 Ok(NodeDeploymentArtifactV1 {
333 path: artifact.path.clone(),
334 digest: artifact.digest.clone(),
335 })
336}
337
338fn is_application_name(value: &str) -> bool {
339 !value.is_empty()
340 && value.len() <= 63
341 && value.chars().all(|character| {
342 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
343 })
344}
345
346fn is_artifact_path(value: &str) -> bool {
347 !value.is_empty()
348 && !value.starts_with('/')
349 && value.split('/').all(|segment| {
350 !segment.is_empty() && segment != "." && segment != ".." && !segment.contains('\\')
351 })
352}
353
354fn is_route_path(value: &str) -> bool {
355 value.starts_with('/') && !value.contains('?') && !value.contains('#') && !value.contains('\\')
356}
357
358#[cfg(test)]
359mod tests {
360 use presolve_compiler::{
361 ApplicationPublicationArtifactV1, FileRoutePublicationManifestV1,
362 FileRoutePublicationRouteV1,
363 };
364
365 use super::{
366 build_node_deployment_plan_v1, node_static_host_module_v1, NodeDeploymentOptionsV1,
367 };
368
369 fn manifest() -> FileRoutePublicationManifestV1 {
370 FileRoutePublicationManifestV1 {
371 schema_version: 1,
372 compiler_contract: "presolve-file-route-publication:1".into(),
373 profile: "production".into(),
374 routes: vec![
375 FileRoutePublicationRouteV1 {
376 path: "/".into(),
377 entry_component_id: "component:home".into(),
378 artifact_root: "routes/root".into(),
379 layout_component_ids: Vec::new(),
380 },
381 FileRoutePublicationRouteV1 {
382 path: "/posts/:slug".into(),
383 entry_component_id: "component:post".into(),
384 artifact_root: "routes/segment-posts/parameter-slug".into(),
385 layout_component_ids: Vec::new(),
386 },
387 ],
388 artifacts: vec![ApplicationPublicationArtifactV1 {
389 path: "routes/root/index.html".into(),
390 digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
391 .into(),
392 }],
393 }
394 }
395
396 #[test]
397 fn marks_only_compiler_proven_handoff_free_routes_static() {
398 let plan = build_node_deployment_plan_v1(
399 &manifest(),
400 r#"{"schemaVersion":1,"routes":[{"path":"/","loaders":[]},{"path":"/posts/:slug","loaders":[{}]}]}"#,
401 r#"{"schemaVersion":1,"routes":[{"path":"/","actions":[]},{"path":"/posts/:slug","actions":[]}]}"#,
402 &NodeDeploymentOptionsV1 {
403 application_name: "presolve-docs".into(),
404 },
405 )
406 .unwrap();
407 assert_eq!(plan.routes[0].execution, "static");
408 assert_eq!(plan.routes[1].execution, "node");
409 let host = node_static_host_module_v1(&plan);
410 assert!(host.contains("executor required"));
411 assert!(host.contains("routes/segment-posts/parameter-slug"));
412 }
413
414 #[test]
415 fn rejects_handoff_route_sets_that_do_not_equal_the_manifest() {
416 let error = build_node_deployment_plan_v1(
417 &manifest(),
418 r#"{"schemaVersion":1,"routes":[{"path":"/","loaders":[]}]}"#,
419 r#"{"schemaVersion":1,"routes":[{"path":"/","actions":[]},{"path":"/posts/:slug","actions":[]}]}"#,
420 &NodeDeploymentOptionsV1 {
421 application_name: "presolve-docs".into(),
422 },
423 )
424 .unwrap_err();
425 assert_eq!(error.code, "PSNODE1007_HANDOFF_ROUTE_SET_MISMATCH");
426 }
427}