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)]
104#[serde(rename_all = "snake_case")]
105pub enum SemanticPackageFormSubmissionExecutionBoundary {
106 Client,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub enum SemanticPackageFormSubmissionCancellation {
112 Abort,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum SemanticPackageFormSubmissionInput {
118 FormValue,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum SemanticPackageFormSubmissionResult {
124 Void,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct SemanticPackageFormSubmission {
134 pub execution_boundary: SemanticPackageFormSubmissionExecutionBoundary,
135 pub cancellation: SemanticPackageFormSubmissionCancellation,
136 pub input: SemanticPackageFormSubmissionInput,
137 pub result: SemanticPackageFormSubmissionResult,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum SemanticPackageOpaqueExecutionBoundary {
144 Client,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum SemanticPackageOpaqueResumePolicy {
151 ColdFallback,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct SemanticPackageOpaqueTerminal {
161 pub execution_boundary: SemanticPackageOpaqueExecutionBoundary,
162 pub resume: SemanticPackageOpaqueResumePolicy,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct SemanticPackageResourceEndpoint {
172 pub execution_boundary: SemanticPackageResourceExecutionBoundary,
173 pub cancellation: SemanticPackageResourceCancellation,
174 pub resume: SemanticPackageResourceResumePolicy,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct SemanticPackageRouteLoader {
182 pub input: SemanticPackageRouteLoaderInput,
183 pub cache: SemanticPackageServerCachePolicy,
184 pub failure: SemanticPackageRouteLoaderFailure,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct SemanticPackageExport {
190 pub kind: SemanticPackageKind,
191 pub type_signature: String,
192 pub runtime_module: String,
193 pub resume_policy: String,
194 #[serde(default)]
195 pub pure_operation: Option<SemanticPackagePureOperation>,
196 #[serde(default)]
197 pub resource_endpoint: Option<SemanticPackageResourceEndpoint>,
198 #[serde(default)]
199 pub route_loader: Option<SemanticPackageRouteLoader>,
200 #[serde(default)]
201 pub server_action: Option<SemanticPackageServerAction>,
202 #[serde(default)]
203 pub form_submission: Option<SemanticPackageFormSubmission>,
204 #[serde(default)]
205 pub opaque_terminal: Option<SemanticPackageOpaqueTerminal>,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct SemanticPackageContract {
211 pub schema_version: u32,
212 pub package: String,
213 pub version: String,
214 pub integrity: String,
215 pub exports: BTreeMap<String, SemanticPackageExport>,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub enum SemanticPackageContractError {
220 InvalidJson,
221 UnsupportedSchema,
222 InvalidIntegrity,
223 EmptyPackage,
224 EmptyVersion,
225 EmptyExport,
226 InvalidExportContract,
227 InvalidPureOperation,
228 InvalidResourceEndpoint,
229 InvalidRouteLoader,
230 InvalidServerAction,
231 InvalidFormSubmission,
232 InvalidOpaqueTerminal,
233 DuplicateSpecifier,
234}
235
236pub fn parse_semantic_package_contract(
237 json: &str,
238) -> Result<SemanticPackageContract, SemanticPackageContractError> {
239 let contract = serde_json::from_str::<SemanticPackageContract>(json)
240 .map_err(|_| SemanticPackageContractError::InvalidJson)?;
241 if contract.schema_version != SEMANTIC_PACKAGE_CONTRACT_SCHEMA_VERSION {
242 return Err(SemanticPackageContractError::UnsupportedSchema);
243 }
244 if contract.package.is_empty() {
245 return Err(SemanticPackageContractError::EmptyPackage);
246 }
247 if contract.version.is_empty() {
248 return Err(SemanticPackageContractError::EmptyVersion);
249 }
250 if !contract.integrity.starts_with("sha256:")
251 || contract.integrity.len() != 71
252 || !contract.integrity[7..]
253 .chars()
254 .all(|c| c.is_ascii_hexdigit())
255 {
256 return Err(SemanticPackageContractError::InvalidIntegrity);
257 }
258 if contract.exports.is_empty() || contract.exports.keys().any(|name| name.is_empty()) {
259 return Err(SemanticPackageContractError::EmptyExport);
260 }
261 if contract.exports.values().any(|export| {
262 export.type_signature.is_empty()
263 || export.runtime_module.is_empty()
264 || export.resume_policy.is_empty()
265 }) {
266 return Err(SemanticPackageContractError::InvalidExportContract);
267 }
268 if contract
269 .exports
270 .values()
271 .any(|export| export.pure_operation.is_some() && export.kind != SemanticPackageKind::Pure)
272 {
273 return Err(SemanticPackageContractError::InvalidPureOperation);
274 }
275 if contract.exports.values().any(|export| {
276 (export.kind == SemanticPackageKind::Resource) != export.resource_endpoint.is_some()
277 }) {
278 return Err(SemanticPackageContractError::InvalidResourceEndpoint);
279 }
280 if contract.exports.values().any(|export| {
281 let Some(loader) = &export.route_loader else {
282 return false;
283 };
284 let Some(endpoint) = &export.resource_endpoint else {
285 return true;
286 };
287 !matches!(export.kind, SemanticPackageKind::Resource)
288 || !matches!(
289 endpoint.execution_boundary,
290 SemanticPackageResourceExecutionBoundary::Server
291 | SemanticPackageResourceExecutionBoundary::Shared
292 )
293 || match loader.cache.scope {
294 SemanticPackageServerCacheScope::Public => {
295 loader.cache.max_age_seconds.is_none_or(|age| age == 0)
296 }
297 SemanticPackageServerCacheScope::NoStore
298 | SemanticPackageServerCacheScope::Private => {
299 loader.cache.max_age_seconds.is_some()
300 }
301 }
302 }) {
303 return Err(SemanticPackageContractError::InvalidRouteLoader);
304 }
305 if contract.exports.values().any(|export| {
306 (export.kind == SemanticPackageKind::ServerAction) != export.server_action.is_some()
307 || (export.kind == SemanticPackageKind::ServerAction
308 && (export.type_signature != "FormData -> ServerActionResult"
309 || export.resume_policy != "cold_fallback"))
310 }) {
311 return Err(SemanticPackageContractError::InvalidServerAction);
312 }
313 if contract.exports.values().any(|export| {
314 (export.kind == SemanticPackageKind::Capability) != export.form_submission.is_some()
315 || (export.kind == SemanticPackageKind::Capability
316 && (export.type_signature != "(FormValue, AbortSignal) -> Promise<void>"
317 || export.resume_policy != "cold_fallback"))
318 }) {
319 return Err(SemanticPackageContractError::InvalidFormSubmission);
320 }
321 if contract.exports.values().any(|export| {
322 (export.kind == SemanticPackageKind::Opaque) != export.opaque_terminal.is_some()
323 || (export.kind == SemanticPackageKind::Opaque
324 && (export.type_signature != "() -> void"
325 || export.resume_policy != "cold_fallback"))
326 }) {
327 return Err(SemanticPackageContractError::InvalidOpaqueTerminal);
328 }
329 Ok(contract)
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Default)]
333pub struct SemanticPackageResolutionTable {
334 contracts: BTreeMap<String, SemanticPackageContract>,
335}
336
337impl SemanticPackageResolutionTable {
338 pub fn insert(
339 &mut self,
340 specifier: String,
341 contract: SemanticPackageContract,
342 ) -> Result<(), SemanticPackageContractError> {
343 if self.contracts.contains_key(&specifier) {
344 return Err(SemanticPackageContractError::DuplicateSpecifier);
345 }
346 self.contracts.insert(specifier, contract);
347 Ok(())
348 }
349
350 #[must_use]
351 pub fn contract(&self, specifier: &str) -> Option<&SemanticPackageContract> {
352 self.contracts.get(specifier)
353 }
354 #[must_use]
355 pub fn resolve(&self, specifier: &str, export: &str) -> Option<&SemanticPackageExport> {
356 self.contracts.get(specifier)?.exports.get(export)
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 #[test]
364 fn parses_integrity_checked_contract_and_resolves_an_export() {
365 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();
366 let mut table = SemanticPackageResolutionTable::default();
367 table.insert("date-kit".into(), contract).unwrap();
368 assert_eq!(
369 table.resolve("date-kit", "format").unwrap().kind,
370 SemanticPackageKind::Pure
371 );
372 }
373
374 #[test]
375 fn rejects_incomplete_contracts_without_replacing_existing_resolution() {
376 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();
377 let invalid = parse_semantic_package_contract(
378 r#"{"schema_version":1,"package":"date-kit","version":"","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"format":{"kind":"pure","type_signature":"","runtime_module":"","resume_policy":""}}}"#,
379 );
380 assert_eq!(invalid, Err(SemanticPackageContractError::EmptyVersion));
381
382 let mut table = SemanticPackageResolutionTable::default();
383 table.insert("date-kit".into(), valid.clone()).unwrap();
384 assert_eq!(
385 table.insert("date-kit".into(), valid),
386 Err(SemanticPackageContractError::DuplicateSpecifier)
387 );
388 assert_eq!(table.contract("date-kit").unwrap().version, "1.2.3");
389 }
390
391 #[test]
392 fn resource_exports_require_a_closed_endpoint_contract() {
393 let missing_endpoint = parse_semantic_package_contract(
394 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"}}}"#,
395 );
396 assert_eq!(
397 missing_endpoint,
398 Err(SemanticPackageContractError::InvalidResourceEndpoint)
399 );
400
401 let contract = parse_semantic_package_contract(
402 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"}}}}"#,
403 )
404 .expect("resource endpoint contract");
405 assert_eq!(
406 contract.exports["loadProfile"].resource_endpoint,
407 Some(SemanticPackageResourceEndpoint {
408 execution_boundary: SemanticPackageResourceExecutionBoundary::Shared,
409 cancellation: SemanticPackageResourceCancellation::Abort,
410 resume: SemanticPackageResourceResumePolicy::Snapshot,
411 })
412 );
413 }
414
415 #[test]
416 fn opaque_exports_require_the_closed_terminal_contract() {
417 let missing_terminal = parse_semantic_package_contract(
418 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"}}}"#,
419 );
420 assert_eq!(
421 missing_terminal,
422 Err(SemanticPackageContractError::InvalidOpaqueTerminal)
423 );
424
425 let contract = parse_semantic_package_contract(
426 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"}}}}"#,
427 )
428 .expect("opaque terminal contract");
429 assert_eq!(
430 contract.exports["trackPurchase"].opaque_terminal,
431 Some(SemanticPackageOpaqueTerminal {
432 execution_boundary: SemanticPackageOpaqueExecutionBoundary::Client,
433 resume: SemanticPackageOpaqueResumePolicy::ColdFallback,
434 })
435 );
436 }
437
438 #[test]
439 fn validates_route_loader_capabilities_on_server_or_shared_resource_exports() {
440 let contract = parse_semantic_package_contract(
441 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"}}}}"#,
442 )
443 .expect("closed route loader contract");
444 assert_eq!(
445 contract.exports["loadPost"].route_loader,
446 Some(SemanticPackageRouteLoader {
447 input: SemanticPackageRouteLoaderInput::RouteParameters,
448 cache: SemanticPackageServerCachePolicy {
449 scope: SemanticPackageServerCacheScope::Public,
450 max_age_seconds: Some(60),
451 },
452 failure: SemanticPackageRouteLoaderFailure::Typed,
453 })
454 );
455
456 let invalid_client = parse_semantic_package_contract(
457 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"}}}}"#,
458 );
459 assert_eq!(
460 invalid_client,
461 Err(SemanticPackageContractError::InvalidRouteLoader)
462 );
463 }
464
465 #[test]
466 fn validates_closed_server_action_capabilities() {
467 let contract = parse_semantic_package_contract(
468 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"}}}}"#,
469 );
470 assert!(contract.is_ok());
471 }
472
473 #[test]
474 fn form_submission_capabilities_require_the_closed_client_abort_contract() {
475 let contract = parse_semantic_package_contract(
476 r#"{"schema_version":1,"package":"profile-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"saveProfile":{"kind":"capability","type_signature":"(FormValue, AbortSignal) -> Promise<void>","runtime_module":"dist/save-profile.js","resume_policy":"cold_fallback","form_submission":{"execution_boundary":"client","cancellation":"abort","input":"form_value","result":"void"}}}}"#,
477 )
478 .expect("closed Form submission contract");
479 assert_eq!(
480 contract.exports["saveProfile"].form_submission,
481 Some(SemanticPackageFormSubmission {
482 execution_boundary: SemanticPackageFormSubmissionExecutionBoundary::Client,
483 cancellation: SemanticPackageFormSubmissionCancellation::Abort,
484 input: SemanticPackageFormSubmissionInput::FormValue,
485 result: SemanticPackageFormSubmissionResult::Void,
486 })
487 );
488
489 let missing = parse_semantic_package_contract(
490 r#"{"schema_version":1,"package":"profile-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"saveProfile":{"kind":"capability","type_signature":"(FormValue, AbortSignal) -> Promise<void>","runtime_module":"dist/save-profile.js","resume_policy":"cold_fallback"}}}"#,
491 );
492 assert_eq!(
493 missing,
494 Err(SemanticPackageContractError::InvalidFormSubmission)
495 );
496 }
497}