1use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::ApplicationSemanticModel;
8
9pub const RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION: u32 = 2;
10pub const PACKAGE_INVOCATION_REGISTRY_PATH: &str = "/presolve.package-invocations.js";
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct RuntimePackageInvocationArtifact {
15 pub schema_version: u32,
16 pub registry_path: String,
17 pub invocations: Vec<RuntimePackageInvocation>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct RuntimePackageInvocation {
23 pub id: String,
24 pub owner_component: String,
25 pub method: String,
26 pub module_specifier: String,
27 pub export: String,
28 pub owner_source: String,
29 pub declaration_modules: Vec<String>,
30 pub arguments: Vec<RuntimePackageInvocationArgument>,
31 pub completion: String,
32 pub inject_abort_signal: bool,
33 pub concurrency: String,
34 pub cancellation: String,
35 pub execution_boundary: String,
36 pub resume_policy: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct RuntimePackageInvocationArgument {
42 pub event_argument_index: usize,
43 pub codec: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum RuntimePackageInvocationArtifactValidationError {
48 UnsupportedSchemaVersion { actual: u32 },
49 InvalidRegistryPath,
50 DuplicateInvocation { id: String },
51 MissingCoordinate { id: String },
52 InvalidLifecycle { id: String },
53}
54
55#[must_use]
56pub fn build_runtime_package_invocation_artifact(
57 model: &ApplicationSemanticModel,
58) -> RuntimePackageInvocationArtifact {
59 let mut invocations = model
60 .terminal_package_invocations
61 .iter()
62 .map(|fact| {
63 let mut declaration_modules = fact.declaration_modules.clone();
64 declaration_modules.sort();
65 declaration_modules.dedup();
66 RuntimePackageInvocation {
67 id: fact.id.to_string(),
68 owner_component: fact.owner_component.to_string(),
69 method: fact.method.to_string(),
70 module_specifier: fact.module_specifier.clone(),
71 export: fact.export_name.clone(),
72 owner_source: fact.provenance.path.to_string_lossy().into_owned(),
73 declaration_modules,
74 arguments: fact
75 .argument_types
76 .iter()
77 .enumerate()
78 .map(
79 |(event_argument_index, codec)| RuntimePackageInvocationArgument {
80 event_argument_index,
81 codec: codec.clone(),
82 },
83 )
84 .collect(),
85 completion: match fact.completion {
86 crate::PackageInvocationCompletionV1::Synchronous => "synchronous".into(),
87 crate::PackageInvocationCompletionV1::Promise => "promise".into(),
88 },
89 inject_abort_signal: fact.inject_abort_signal,
90 concurrency: if fact.completion == crate::PackageInvocationCompletionV1::Promise {
91 "replace_previous_per_component_instance".into()
92 } else {
93 "none".into()
94 },
95 cancellation: if fact.completion == crate::PackageInvocationCompletionV1::Promise {
96 "abort_on_replacement_teardown_or_pagehide".into()
97 } else {
98 "none".into()
99 },
100 execution_boundary: "client".into(),
101 resume_policy: "restore_event_without_replay".into(),
102 }
103 })
104 .collect::<Vec<_>>();
105 invocations.sort_by(|left, right| left.id.cmp(&right.id));
106 RuntimePackageInvocationArtifact {
107 schema_version: RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION,
108 registry_path: PACKAGE_INVOCATION_REGISTRY_PATH.into(),
109 invocations,
110 }
111}
112
113#[must_use]
114pub fn runtime_package_invocation_artifact_json(
115 artifact: &RuntimePackageInvocationArtifact,
116) -> String {
117 serde_json::to_string_pretty(artifact).expect("package invocation artifact should serialize")
118 + "\n"
119}
120
121#[must_use]
122pub fn validate_runtime_package_invocation_artifact(
123 artifact: &RuntimePackageInvocationArtifact,
124) -> Vec<RuntimePackageInvocationArtifactValidationError> {
125 let mut errors = Vec::new();
126 if artifact.schema_version != RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION {
127 errors.push(
128 RuntimePackageInvocationArtifactValidationError::UnsupportedSchemaVersion {
129 actual: artifact.schema_version,
130 },
131 );
132 }
133 if artifact.registry_path != PACKAGE_INVOCATION_REGISTRY_PATH {
134 errors.push(RuntimePackageInvocationArtifactValidationError::InvalidRegistryPath);
135 }
136 let mut ids = BTreeSet::new();
137 for invocation in &artifact.invocations {
138 if !ids.insert(invocation.id.clone()) {
139 errors.push(
140 RuntimePackageInvocationArtifactValidationError::DuplicateInvocation {
141 id: invocation.id.clone(),
142 },
143 );
144 }
145 if invocation.id.is_empty()
146 || invocation.owner_component.is_empty()
147 || invocation.method.is_empty()
148 || invocation.module_specifier.is_empty()
149 || invocation.export.is_empty()
150 || invocation.owner_source.is_empty()
151 || invocation.declaration_modules.is_empty()
152 {
153 errors.push(
154 RuntimePackageInvocationArtifactValidationError::MissingCoordinate {
155 id: invocation.id.clone(),
156 },
157 );
158 }
159 let valid_arguments = invocation
160 .arguments
161 .iter()
162 .enumerate()
163 .all(|(index, argument)| {
164 argument.event_argument_index == index
165 && matches!(
166 argument.codec.as_str(),
167 "string" | "number" | "boolean" | "null"
168 )
169 });
170 let valid_completion = match invocation.completion.as_str() {
171 "synchronous" => {
172 !invocation.inject_abort_signal
173 && invocation.concurrency == "none"
174 && invocation.cancellation == "none"
175 }
176 "promise" => {
177 invocation.inject_abort_signal
178 && invocation.concurrency == "replace_previous_per_component_instance"
179 && invocation.cancellation == "abort_on_replacement_teardown_or_pagehide"
180 }
181 _ => false,
182 };
183 if invocation.execution_boundary != "client"
184 || invocation.resume_policy != "restore_event_without_replay"
185 || !valid_arguments
186 || !valid_completion
187 {
188 errors.push(
189 RuntimePackageInvocationArtifactValidationError::InvalidLifecycle {
190 id: invocation.id.clone(),
191 },
192 );
193 }
194 }
195 errors
196}
197
198#[cfg(test)]
199mod tests {
200 use super::{
201 runtime_package_invocation_artifact_json, validate_runtime_package_invocation_artifact,
202 RuntimePackageInvocation, RuntimePackageInvocationArgument,
203 RuntimePackageInvocationArtifact, RuntimePackageInvocationArtifactValidationError,
204 PACKAGE_INVOCATION_REGISTRY_PATH, RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION,
205 };
206
207 fn artifact() -> RuntimePackageInvocationArtifact {
208 RuntimePackageInvocationArtifact {
209 schema_version: RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION,
210 registry_path: PACKAGE_INVOCATION_REGISTRY_PATH.into(),
211 invocations: vec![RuntimePackageInvocation {
212 id: "component:Home/package-invocation:record".into(),
213 owner_component: "component:Home".into(),
214 method: "component:Home/action-endpoint:record".into(),
215 module_specifier: "analytics-kit".into(),
216 export: "recordVisit".into(),
217 owner_source: "app/routes/index.tsx".into(),
218 declaration_modules: vec!["node_modules/analytics-kit/index.d.ts".into()],
219 arguments: vec![RuntimePackageInvocationArgument {
220 event_argument_index: 0,
221 codec: "string".into(),
222 }],
223 completion: "synchronous".into(),
224 inject_abort_signal: false,
225 concurrency: "none".into(),
226 cancellation: "none".into(),
227 execution_boundary: "client".into(),
228 resume_policy: "restore_event_without_replay".into(),
229 }],
230 }
231 }
232
233 #[test]
234 fn validates_and_serializes_exact_package_invocation_coordinates() {
235 let artifact = artifact();
236 assert!(validate_runtime_package_invocation_artifact(&artifact).is_empty());
237 let json = runtime_package_invocation_artifact_json(&artifact);
238 assert!(json.contains("\"registry_path\": \"/presolve.package-invocations.js\""));
239 assert!(json.contains("\"module_specifier\": \"analytics-kit\""));
240 }
241
242 #[test]
243 fn rejects_duplicate_missing_and_noncanonical_package_invocations() {
244 let mut artifact = artifact();
245 artifact.schema_version += 1;
246 artifact.registry_path = "relative.js".into();
247 artifact.invocations[0].module_specifier.clear();
248 artifact.invocations[0].resume_policy = "snapshot".into();
249 artifact.invocations[0].arguments[0].codec = "object".into();
250 artifact.invocations.push(artifact.invocations[0].clone());
251 let errors = validate_runtime_package_invocation_artifact(&artifact);
252 assert!(errors.iter().any(|error| matches!(
253 error,
254 RuntimePackageInvocationArtifactValidationError::UnsupportedSchemaVersion { .. }
255 )));
256 assert!(errors.iter().any(|error| matches!(
257 error,
258 RuntimePackageInvocationArtifactValidationError::InvalidRegistryPath
259 )));
260 assert!(errors.iter().any(|error| matches!(
261 error,
262 RuntimePackageInvocationArtifactValidationError::DuplicateInvocation { .. }
263 )));
264 assert!(errors.iter().any(|error| matches!(
265 error,
266 RuntimePackageInvocationArtifactValidationError::MissingCoordinate { .. }
267 )));
268 assert!(errors.iter().any(|error| matches!(
269 error,
270 RuntimePackageInvocationArtifactValidationError::InvalidLifecycle { .. }
271 )));
272 }
273}