1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5pub const SEMANTIC_PACKAGE_CONTRACT_SCHEMA_VERSION: u32 = 1;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum SemanticPackageKind {
10 Pure,
11 Capability,
12 Resource,
13 Codec,
14 Component,
15 Opaque,
16 ServerAction,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum SemanticPackagePureOperation {
23 Identity,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum SemanticPackageResourceExecutionBoundary {
30 Client,
31 Server,
32 Shared,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum SemanticPackageResourceCancellation {
39 Abort,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum SemanticPackageResourceResumePolicy {
46 Reload,
47 Snapshot,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum SemanticPackageRouteLoaderInput {
54 RouteParameters,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum SemanticPackageServerCacheScope {
61 NoStore,
62 Private,
63 Public,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct SemanticPackageServerCachePolicy {
70 pub scope: SemanticPackageServerCacheScope,
71 #[serde(default)]
72 pub max_age_seconds: Option<u64>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum SemanticPackageRouteLoaderFailure {
79 Typed,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum SemanticPackageServerActionInput {
85 FormData,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum SemanticPackageServerActionResponse {
91 Json,
92 Redirect,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct SemanticPackageServerAction {
98 pub input: SemanticPackageServerActionInput,
99 pub response: SemanticPackageServerActionResponse,
100 pub failure: SemanticPackageRouteLoaderFailure,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum SemanticPackageOpaqueExecutionBoundary {
107 Client,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum SemanticPackageOpaqueResumePolicy {
114 ColdFallback,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct SemanticPackageOpaqueTerminal {
124 pub execution_boundary: SemanticPackageOpaqueExecutionBoundary,
125 pub resume: SemanticPackageOpaqueResumePolicy,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct SemanticPackageResourceEndpoint {
135 pub execution_boundary: SemanticPackageResourceExecutionBoundary,
136 pub cancellation: SemanticPackageResourceCancellation,
137 pub resume: SemanticPackageResourceResumePolicy,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct SemanticPackageRouteLoader {
145 pub input: SemanticPackageRouteLoaderInput,
146 pub cache: SemanticPackageServerCachePolicy,
147 pub failure: SemanticPackageRouteLoaderFailure,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct SemanticPackageExport {
153 pub kind: SemanticPackageKind,
154 pub type_signature: String,
155 pub runtime_module: String,
156 pub resume_policy: String,
157 #[serde(default)]
158 pub pure_operation: Option<SemanticPackagePureOperation>,
159 #[serde(default)]
160 pub resource_endpoint: Option<SemanticPackageResourceEndpoint>,
161 #[serde(default)]
162 pub route_loader: Option<SemanticPackageRouteLoader>,
163 #[serde(default)]
164 pub server_action: Option<SemanticPackageServerAction>,
165 #[serde(default)]
166 pub opaque_terminal: Option<SemanticPackageOpaqueTerminal>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct SemanticPackageContract {
172 pub schema_version: u32,
173 pub package: String,
174 pub version: String,
175 pub integrity: String,
176 pub exports: BTreeMap<String, SemanticPackageExport>,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum SemanticPackageContractError {
181 InvalidJson,
182 UnsupportedSchema,
183 InvalidIntegrity,
184 EmptyPackage,
185 EmptyVersion,
186 EmptyExport,
187 InvalidExportContract,
188 InvalidPureOperation,
189 InvalidResourceEndpoint,
190 InvalidRouteLoader,
191 InvalidServerAction,
192 InvalidOpaqueTerminal,
193 DuplicateSpecifier,
194}
195
196pub fn parse_semantic_package_contract(
197 json: &str,
198) -> Result<SemanticPackageContract, SemanticPackageContractError> {
199 let contract = serde_json::from_str::<SemanticPackageContract>(json)
200 .map_err(|_| SemanticPackageContractError::InvalidJson)?;
201 if contract.schema_version != SEMANTIC_PACKAGE_CONTRACT_SCHEMA_VERSION {
202 return Err(SemanticPackageContractError::UnsupportedSchema);
203 }
204 if contract.package.is_empty() {
205 return Err(SemanticPackageContractError::EmptyPackage);
206 }
207 if contract.version.is_empty() {
208 return Err(SemanticPackageContractError::EmptyVersion);
209 }
210 if !contract.integrity.starts_with("sha256:")
211 || contract.integrity.len() != 71
212 || !contract.integrity[7..]
213 .chars()
214 .all(|c| c.is_ascii_hexdigit())
215 {
216 return Err(SemanticPackageContractError::InvalidIntegrity);
217 }
218 if contract.exports.is_empty() || contract.exports.keys().any(|name| name.is_empty()) {
219 return Err(SemanticPackageContractError::EmptyExport);
220 }
221 if contract.exports.values().any(|export| {
222 export.type_signature.is_empty()
223 || export.runtime_module.is_empty()
224 || export.resume_policy.is_empty()
225 }) {
226 return Err(SemanticPackageContractError::InvalidExportContract);
227 }
228 if contract
229 .exports
230 .values()
231 .any(|export| export.pure_operation.is_some() && export.kind != SemanticPackageKind::Pure)
232 {
233 return Err(SemanticPackageContractError::InvalidPureOperation);
234 }
235 if contract.exports.values().any(|export| {
236 (export.kind == SemanticPackageKind::Resource) != export.resource_endpoint.is_some()
237 }) {
238 return Err(SemanticPackageContractError::InvalidResourceEndpoint);
239 }
240 if contract.exports.values().any(|export| {
241 let Some(loader) = &export.route_loader else {
242 return false;
243 };
244 let Some(endpoint) = &export.resource_endpoint else {
245 return true;
246 };
247 !matches!(export.kind, SemanticPackageKind::Resource)
248 || !matches!(
249 endpoint.execution_boundary,
250 SemanticPackageResourceExecutionBoundary::Server
251 | SemanticPackageResourceExecutionBoundary::Shared
252 )
253 || match loader.cache.scope {
254 SemanticPackageServerCacheScope::Public => {
255 loader.cache.max_age_seconds.is_none_or(|age| age == 0)
256 }
257 SemanticPackageServerCacheScope::NoStore
258 | SemanticPackageServerCacheScope::Private => {
259 loader.cache.max_age_seconds.is_some()
260 }
261 }
262 }) {
263 return Err(SemanticPackageContractError::InvalidRouteLoader);
264 }
265 if contract.exports.values().any(|export| {
266 (export.kind == SemanticPackageKind::ServerAction) != export.server_action.is_some()
267 || (export.kind == SemanticPackageKind::ServerAction
268 && (export.type_signature != "FormData -> ServerActionResult"
269 || export.resume_policy != "cold_fallback"))
270 }) {
271 return Err(SemanticPackageContractError::InvalidServerAction);
272 }
273 if contract.exports.values().any(|export| {
274 (export.kind == SemanticPackageKind::Opaque) != export.opaque_terminal.is_some()
275 || (export.kind == SemanticPackageKind::Opaque
276 && (export.type_signature != "() -> void"
277 || export.resume_policy != "cold_fallback"))
278 }) {
279 return Err(SemanticPackageContractError::InvalidOpaqueTerminal);
280 }
281 Ok(contract)
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Default)]
285pub struct SemanticPackageResolutionTable {
286 contracts: BTreeMap<String, SemanticPackageContract>,
287}
288
289impl SemanticPackageResolutionTable {
290 pub fn insert(
291 &mut self,
292 specifier: String,
293 contract: SemanticPackageContract,
294 ) -> Result<(), SemanticPackageContractError> {
295 if self.contracts.contains_key(&specifier) {
296 return Err(SemanticPackageContractError::DuplicateSpecifier);
297 }
298 self.contracts.insert(specifier, contract);
299 Ok(())
300 }
301
302 #[must_use]
303 pub fn contract(&self, specifier: &str) -> Option<&SemanticPackageContract> {
304 self.contracts.get(specifier)
305 }
306 #[must_use]
307 pub fn resolve(&self, specifier: &str, export: &str) -> Option<&SemanticPackageExport> {
308 self.contracts.get(specifier)?.exports.get(export)
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 #[test]
316 fn parses_integrity_checked_contract_and_resolves_an_export() {
317 let contract = parse_semantic_package_contract(r#"{"schema_version":1,"package":"date-kit","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"format":{"kind":"pure","type_signature":"(Date) -> string","runtime_module":"dist/format.js","resume_policy":"input_only"}}}"#).unwrap();
318 let mut table = SemanticPackageResolutionTable::default();
319 table.insert("date-kit".into(), contract).unwrap();
320 assert_eq!(
321 table.resolve("date-kit", "format").unwrap().kind,
322 SemanticPackageKind::Pure
323 );
324 }
325
326 #[test]
327 fn rejects_incomplete_contracts_without_replacing_existing_resolution() {
328 let valid = parse_semantic_package_contract(r#"{"schema_version":1,"package":"date-kit","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"format":{"kind":"pure","type_signature":"(Date) -> string","runtime_module":"dist/format.js","resume_policy":"input_only"}}}"#).unwrap();
329 let invalid = parse_semantic_package_contract(
330 r#"{"schema_version":1,"package":"date-kit","version":"","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"format":{"kind":"pure","type_signature":"","runtime_module":"","resume_policy":""}}}"#,
331 );
332 assert_eq!(invalid, Err(SemanticPackageContractError::EmptyVersion));
333
334 let mut table = SemanticPackageResolutionTable::default();
335 table.insert("date-kit".into(), valid.clone()).unwrap();
336 assert_eq!(
337 table.insert("date-kit".into(), valid),
338 Err(SemanticPackageContractError::DuplicateSpecifier)
339 );
340 assert_eq!(table.contract("date-kit").unwrap().version, "1.2.3");
341 }
342
343 #[test]
344 fn resource_exports_require_a_closed_endpoint_contract() {
345 let missing_endpoint = parse_semantic_package_contract(
346 r#"{"schema_version":1,"package":"profile-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"loadProfile":{"kind":"resource","type_signature":"(ProfileKey) -> Resource<Profile, ProfileError>","runtime_module":"dist/load-profile.js","resume_policy":"snapshot"}}}"#,
347 );
348 assert_eq!(
349 missing_endpoint,
350 Err(SemanticPackageContractError::InvalidResourceEndpoint)
351 );
352
353 let contract = parse_semantic_package_contract(
354 r#"{"schema_version":1,"package":"profile-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"loadProfile":{"kind":"resource","type_signature":"(ProfileKey) -> Resource<Profile, ProfileError>","runtime_module":"dist/load-profile.js","resume_policy":"snapshot","resource_endpoint":{"execution_boundary":"shared","cancellation":"abort","resume":"snapshot"}}}}"#,
355 )
356 .expect("resource endpoint contract");
357 assert_eq!(
358 contract.exports["loadProfile"].resource_endpoint,
359 Some(SemanticPackageResourceEndpoint {
360 execution_boundary: SemanticPackageResourceExecutionBoundary::Shared,
361 cancellation: SemanticPackageResourceCancellation::Abort,
362 resume: SemanticPackageResourceResumePolicy::Snapshot,
363 })
364 );
365 }
366
367 #[test]
368 fn opaque_exports_require_the_closed_terminal_contract() {
369 let missing_terminal = parse_semantic_package_contract(
370 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"}}}"#,
371 );
372 assert_eq!(
373 missing_terminal,
374 Err(SemanticPackageContractError::InvalidOpaqueTerminal)
375 );
376
377 let contract = parse_semantic_package_contract(
378 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"}}}}"#,
379 )
380 .expect("opaque terminal contract");
381 assert_eq!(
382 contract.exports["trackPurchase"].opaque_terminal,
383 Some(SemanticPackageOpaqueTerminal {
384 execution_boundary: SemanticPackageOpaqueExecutionBoundary::Client,
385 resume: SemanticPackageOpaqueResumePolicy::ColdFallback,
386 })
387 );
388 }
389
390 #[test]
391 fn validates_route_loader_capabilities_on_server_or_shared_resource_exports() {
392 let contract = parse_semantic_package_contract(
393 r#"{"schema_version":1,"package":"post-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"loadPost":{"kind":"resource","type_signature":"RouteParameters -> Resource<Post, NotFound>","runtime_module":"dist/load-post.js","resume_policy":"reload","resource_endpoint":{"execution_boundary":"server","cancellation":"abort","resume":"reload"},"route_loader":{"input":"route_parameters","cache":{"scope":"public","max_age_seconds":60},"failure":"typed"}}}}"#,
394 )
395 .expect("closed route loader contract");
396 assert_eq!(
397 contract.exports["loadPost"].route_loader,
398 Some(SemanticPackageRouteLoader {
399 input: SemanticPackageRouteLoaderInput::RouteParameters,
400 cache: SemanticPackageServerCachePolicy {
401 scope: SemanticPackageServerCacheScope::Public,
402 max_age_seconds: Some(60),
403 },
404 failure: SemanticPackageRouteLoaderFailure::Typed,
405 })
406 );
407
408 let invalid_client = parse_semantic_package_contract(
409 r#"{"schema_version":1,"package":"post-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"loadPost":{"kind":"resource","type_signature":"RouteParameters -> Resource<Post, NotFound>","runtime_module":"dist/load-post.js","resume_policy":"reload","resource_endpoint":{"execution_boundary":"client","cancellation":"abort","resume":"reload"},"route_loader":{"input":"route_parameters","cache":{"scope":"no_store"},"failure":"typed"}}}}"#,
410 );
411 assert_eq!(
412 invalid_client,
413 Err(SemanticPackageContractError::InvalidRouteLoader)
414 );
415 }
416
417 #[test]
418 fn validates_closed_server_action_capabilities() {
419 let contract = parse_semantic_package_contract(
420 r#"{"schema_version":1,"package":"post-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"savePost":{"kind":"server_action","type_signature":"FormData -> ServerActionResult","runtime_module":"dist/save-post.js","resume_policy":"cold_fallback","server_action":{"input":"form_data","response":"json","failure":"typed"}}}}"#,
421 );
422 assert!(contract.is_ok());
423 }
424}