1use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::{
8 ApplicationSemanticModel, OpaqueActionResolutionOutcome, SemanticPackageRuntimeModuleKey,
9 SemanticPackageRuntimeModuleTable,
10};
11
12pub const RUNTIME_OPAQUE_ARTIFACT_SCHEMA_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct RuntimeOpaqueArtifact {
17 pub schema_version: u32,
18 pub activations: Vec<RuntimeOpaqueArtifactActivation>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct RuntimeOpaqueArtifactActivation {
24 pub id: String,
25 pub owner_component: String,
26 pub method: String,
27 pub package: String,
28 pub version: String,
29 pub integrity: String,
30 pub export: String,
31 pub type_signature: String,
32 pub runtime_module: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub runtime_location: Option<String>,
35 pub execution_boundary: String,
36 pub resume_policy: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum RuntimeOpaqueArtifactBuildError {
41 MissingRuntimeModuleLocation { activation: String },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum RuntimeOpaqueArtifactValidationError {
46 UnsupportedSchemaVersion { actual: u32 },
47 DuplicateActivationId { id: String },
48 MissingTerminalCoordinate { activation: String },
49 InvalidTerminalContract { activation: String },
50}
51
52#[must_use]
55pub fn build_runtime_opaque_artifact(model: &ApplicationSemanticModel) -> RuntimeOpaqueArtifact {
56 let activations = model
57 .opaque_action_resolutions
58 .iter()
59 .filter_map(|resolution| {
60 let OpaqueActionResolutionOutcome::Resolved(binding) = &resolution.outcome else {
61 return None;
62 };
63 Some(RuntimeOpaqueArtifactActivation {
64 id: resolution.activation.as_str().to_string(),
65 owner_component: resolution.owner_component.as_str().to_string(),
66 method: resolution.method.as_str().to_string(),
67 package: binding.package.clone(),
68 version: binding.version.clone(),
69 integrity: binding.integrity.clone(),
70 export: binding.export.clone(),
71 type_signature: binding.type_signature.clone(),
72 runtime_module: binding.runtime_module.clone(),
73 runtime_location: None,
74 execution_boundary: "client".to_string(),
75 resume_policy: binding.resume_policy.clone(),
76 })
77 })
78 .collect();
79 RuntimeOpaqueArtifact {
80 schema_version: RUNTIME_OPAQUE_ARTIFACT_SCHEMA_VERSION,
81 activations,
82 }
83}
84
85pub fn build_runtime_opaque_artifact_with_modules(
88 model: &ApplicationSemanticModel,
89 modules: &SemanticPackageRuntimeModuleTable,
90) -> Result<RuntimeOpaqueArtifact, RuntimeOpaqueArtifactBuildError> {
91 let mut artifact = build_runtime_opaque_artifact(model);
92 for activation in &mut artifact.activations {
93 let key = SemanticPackageRuntimeModuleKey {
94 package: activation.package.clone(),
95 version: activation.version.clone(),
96 integrity: activation.integrity.clone(),
97 runtime_module: activation.runtime_module.clone(),
98 };
99 activation.runtime_location = Some(
100 modules
101 .resolve(&key)
102 .ok_or_else(
103 || RuntimeOpaqueArtifactBuildError::MissingRuntimeModuleLocation {
104 activation: activation.id.clone(),
105 },
106 )?
107 .to_string(),
108 );
109 }
110 Ok(artifact)
111}
112
113#[must_use]
114pub fn runtime_opaque_artifact_json(artifact: &RuntimeOpaqueArtifact) -> String {
115 serde_json::to_string_pretty(artifact).expect("opaque artifact should serialize") + "\n"
116}
117
118#[must_use]
119pub fn validate_runtime_opaque_artifact(
120 artifact: &RuntimeOpaqueArtifact,
121) -> Vec<RuntimeOpaqueArtifactValidationError> {
122 let mut errors = Vec::new();
123 if artifact.schema_version != RUNTIME_OPAQUE_ARTIFACT_SCHEMA_VERSION {
124 errors.push(
125 RuntimeOpaqueArtifactValidationError::UnsupportedSchemaVersion {
126 actual: artifact.schema_version,
127 },
128 );
129 }
130 let mut ids = BTreeSet::new();
131 for activation in &artifact.activations {
132 if !ids.insert(activation.id.clone()) {
133 errors.push(
134 RuntimeOpaqueArtifactValidationError::DuplicateActivationId {
135 id: activation.id.clone(),
136 },
137 );
138 }
139 if activation.id.is_empty()
140 || activation.owner_component.is_empty()
141 || activation.method.is_empty()
142 || activation.package.is_empty()
143 || activation.version.is_empty()
144 || activation.integrity.is_empty()
145 || activation.export.is_empty()
146 || activation.runtime_module.is_empty()
147 {
148 errors.push(
149 RuntimeOpaqueArtifactValidationError::MissingTerminalCoordinate {
150 activation: activation.id.clone(),
151 },
152 );
153 }
154 if activation.type_signature != "() -> void"
155 || activation.execution_boundary != "client"
156 || activation.resume_policy != "cold_fallback"
157 {
158 errors.push(
159 RuntimeOpaqueArtifactValidationError::InvalidTerminalContract {
160 activation: activation.id.clone(),
161 },
162 );
163 }
164 }
165 errors
166}
167
168#[cfg(test)]
169mod tests {
170 use crate::{
171 build_application_semantic_model_for_unit_with_packages, build_runtime_opaque_artifact,
172 build_runtime_opaque_artifact_with_modules, parse_semantic_package_contract,
173 validate_runtime_opaque_artifact, CompilationUnit, RuntimeOpaqueArtifactBuildError,
174 RuntimeOpaqueArtifactValidationError, SemanticPackageResolutionTable,
175 SemanticPackageRuntimeModuleKey, SemanticPackageRuntimeModuleTable,
176 };
177
178 fn model() -> crate::ApplicationSemanticModel {
179 let unit = CompilationUnit::parse_sources([(
180 "src/Checkout.tsx",
181 r#"
182import { trackPurchase } from "@acme/analytics";
183@component("x-checkout")
184class Checkout extends Component {
185 @action() @opaque("@acme/analytics", "trackPurchase") track(): void {}
186 render() { return <button onClick={this.track}>Buy</button>; }
187}
188"#,
189 )]);
190 let contract = parse_semantic_package_contract(
191 r#"{"schema_version":1,"package":"@acme/analytics","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"trackPurchase":{"kind":"opaque","type_signature":"() -> void","runtime_module":"dist/track.js","resume_policy":"cold_fallback","opaque_terminal":{"execution_boundary":"client","resume":"cold_fallback"}}}}"#,
192 )
193 .unwrap();
194 let mut packages = SemanticPackageResolutionTable::default();
195 packages.insert("@acme/analytics".into(), contract).unwrap();
196 build_application_semantic_model_for_unit_with_packages(&unit, &packages)
197 }
198
199 #[test]
200 fn projects_and_validates_resolved_opaque_terminal_artifact() {
201 let artifact = build_runtime_opaque_artifact(&model());
202 assert_eq!(artifact.activations.len(), 1);
203 assert_eq!(artifact.activations[0].export, "trackPurchase");
204 assert_eq!(artifact.activations[0].execution_boundary, "client");
205 assert!(validate_runtime_opaque_artifact(&artifact).is_empty());
206 }
207
208 #[test]
209 fn requires_an_exact_host_bound_runtime_module_location() {
210 let model = model();
211 let modules = SemanticPackageRuntimeModuleTable::default();
212 assert!(matches!(
213 build_runtime_opaque_artifact_with_modules(&model, &modules),
214 Err(RuntimeOpaqueArtifactBuildError::MissingRuntimeModuleLocation { .. })
215 ));
216
217 let mut modules = SemanticPackageRuntimeModuleTable::default();
218 modules
219 .insert(
220 SemanticPackageRuntimeModuleKey {
221 package: "@acme/analytics".into(),
222 version: "1.2.3".into(),
223 integrity:
224 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
225 .into(),
226 runtime_module: "dist/track.js".into(),
227 },
228 "./vendor/track.js".into(),
229 )
230 .unwrap();
231 let artifact = build_runtime_opaque_artifact_with_modules(&model, &modules).unwrap();
232 assert_eq!(
233 artifact.activations[0].runtime_location.as_deref(),
234 Some("./vendor/track.js")
235 );
236 }
237
238 #[test]
239 fn rejects_malformed_opaque_terminal_artifacts() {
240 let mut artifact = build_runtime_opaque_artifact(&model());
241 artifact.schema_version = 2;
242 artifact.activations[0].type_signature = "(value: string) -> void".into();
243 artifact.activations[0].integrity.clear();
244 let errors = validate_runtime_opaque_artifact(&artifact);
245 assert!(errors.iter().any(|error| matches!(
246 error,
247 RuntimeOpaqueArtifactValidationError::UnsupportedSchemaVersion { actual: 2 }
248 )));
249 assert!(errors.iter().any(|error| matches!(
250 error,
251 RuntimeOpaqueArtifactValidationError::InvalidTerminalContract { .. }
252 )));
253 assert!(errors.iter().any(|error| matches!(
254 error,
255 RuntimeOpaqueArtifactValidationError::MissingTerminalCoordinate { .. }
256 )));
257 }
258}