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) {{\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}