use std::path::Path;
use anyhow::Result;
use rspyts::ir::{
BufferElement, DefinitionId, FieldDef, ScalarValue, Target, TypeDef, TypeRef, TypeShape,
};
use serde_json::Value;
use crate::config::{TypeScriptConfig, TypeScriptMode};
use crate::resolve::ResolvedContract;
use super::util::{
TypeNames, error_definition, ordered_types, ts_doc, ts_property, type_definition, type_names,
};
use super::{write, write_json};
pub fn emit(
root: &Path,
config: &TypeScriptConfig,
contract: &ResolvedContract,
fingerprint: &str,
) -> Result<()> {
let manifest = &contract.manifest;
let output = root.join("typescript");
let names = type_names(contract);
let declarations = declarations(contract, fingerprint, &names, config.mode);
match config.mode {
TypeScriptMode::Wasm => {
write(&output.join("index.d.ts"), &declarations)?;
write(
&output.join("index.js"),
&wasm_runtime(contract, fingerprint),
)?;
}
TypeScriptMode::Static => {
write(&output.join("index.d.ts"), &declarations)?;
write(
&output.join("index.js"),
&static_runtime(contract, fingerprint),
)?;
}
}
let peer_dependencies = contract
.dependencies
.values()
.filter_map(|dependency| dependency.typescript.as_ref())
.map(|package| (package.clone(), "*"))
.collect::<std::collections::BTreeMap<_, _>>();
write_json(
&output.join("package.json"),
&serde_json::json!({
"name": config.package,
"version": manifest.crate_version,
"type": "module",
"sideEffects": false,
"main": "./index.js",
"types": "./index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.js",
"default": "./index.js"
},
"./contract.json": "./contract.json"
},
"files": ["index.js", "index.d.ts", "contract.json", "native.js", "native_bg.wasm"]
,"peerDependencies": peer_dependencies
}),
)?;
write_json(
&output.join("contract.json"),
&serde_json::json!({
"schemaVersion": crate::LOCK_VERSION,
"fingerprint": fingerprint,
"hosts": contract.hosts,
"dependencies": contract.dependencies,
"manifest": manifest,
}),
)
}
fn declarations(
contract: &ResolvedContract,
fingerprint: &str,
names: &TypeNames,
mode: TypeScriptMode,
) -> String {
let manifest = &contract.manifest;
let mut output = String::from(
"// Generated by rspyts 0.4. Do not edit.\n\
export type JsonValue = null | boolean | number | string | readonly JsonValue[] | { readonly [key: string]: JsonValue };\n\n",
);
output.push_str(&typescript_declaration_imports(contract));
if mode == TypeScriptMode::Wasm {
output.push_str(
"export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;\n\
export interface InitOutput { readonly memory: WebAssembly.Memory; }\n\
export default function init(input?: InitInput | Promise<InitInput>): Promise<InitOutput>;\n\n",
);
}
output.push_str(&format!(
"export declare const CONTRACT_FINGERPRINT: {};\n\n",
json_string(fingerprint)
));
if mode == TypeScriptMode::Wasm {
output.push_str(
"export declare class ResourceClosedError extends globalThis.Error {\n constructor(resource: string);\n}\n\n",
);
}
for item in ordered_types(manifest) {
output.push_str(&typescript_type(item, names, contract));
output.push('\n');
}
for error in manifest
.errors
.iter()
.filter(|_| mode == TypeScriptMode::Wasm)
{
output.push_str(&ts_doc(error.docs.as_deref()));
output.push_str(&format!(
"export declare class {} extends globalThis.Error {{\n readonly code: string;\n constructor(message: string, code: string);\n}}\n\n",
error.name
));
}
for function in manifest.functions.iter().filter(|function| {
mode == TypeScriptMode::Wasm && matches!(function.target, Target::Both | Target::Typescript)
}) {
output.push_str(&ts_doc(function.docs.as_deref()));
let declaration = if mode == TypeScriptMode::Wasm {
"export function"
} else {
"export declare function"
};
output.push_str(&format!(
"{declaration} {}({}): {};\n\n",
function.host_name,
function
.params
.iter()
.map(|param| format!("{}: {}", param.host_name, typescript_ref(¶m.ty, names)))
.collect::<Vec<_>>()
.join(", "),
typescript_ref(&function.returns, names)
));
}
for resource in manifest.resources.iter().filter(|resource| {
mode == TypeScriptMode::Wasm && matches!(resource.target, Target::Both | Target::Typescript)
}) {
output.push_str(&ts_doc(resource.docs.as_deref()));
output.push_str(&format!("export declare class {} {{\n", resource.name));
let constructors = resource
.constructors
.iter()
.filter(|constructor| matches!(constructor.target, Target::Both | Target::Typescript))
.collect::<Vec<_>>();
let primary = constructors
.iter()
.copied()
.find(|constructor| constructor.rust_name == "new")
.or_else(|| constructors.first().copied());
if let Some(constructor) = primary {
output.push_str(&format!(
" constructor({});\n",
constructor
.params
.iter()
.map(|param| format!(
"{}: {}",
param.host_name,
typescript_ref(¶m.ty, names)
))
.collect::<Vec<_>>()
.join(", ")
));
}
for constructor in constructors
.into_iter()
.filter(|constructor| Some(*constructor) != primary)
{
output.push_str(&format!(
" static {}({}): {};\n",
constructor.host_name,
constructor
.params
.iter()
.map(|param| format!(
"{}: {}",
param.host_name,
typescript_ref(¶m.ty, names)
))
.collect::<Vec<_>>()
.join(", "),
resource.name,
));
}
for method in resource
.methods
.iter()
.filter(|method| matches!(method.target, Target::Both | Target::Typescript))
{
output.push_str(&format!(
" {}({}): {};\n",
method.host_name,
method
.params
.iter()
.map(|param| format!(
"{}: {}",
param.host_name,
typescript_ref(¶m.ty, names)
))
.collect::<Vec<_>>()
.join(", "),
typescript_ref(&method.returns, names)
));
}
output.push_str(" free(): void;\n [Symbol.dispose](): void;\n}\n\n");
}
for constant in manifest
.constants
.iter()
.filter(|constant| constant_in_typescript(constant.target, mode))
{
output.push_str(&ts_doc(constant.docs.as_deref()));
output.push_str(&format!(
"export const {}: {};\n",
constant.host_name,
typescript_literal_type(&constant.value, &constant.ty, contract, names)
));
}
output
}
fn wasm_runtime(contract: &ResolvedContract, fingerprint: &str) -> String {
let manifest = &contract.manifest;
let mut output = format!(
"// Generated by rspyts 0.4. Do not edit.\n{}\
import initializeNative, * as native from \"./native.js\";\n\n\
export const CONTRACT_FINGERPRINT = {};\n\n\
let initializationPromise;\n\n\
export default function init(input) {{\n\
if (initializationPromise === undefined) {{\n\
initializationPromise = Promise.resolve()\n\
.then(() => initializeNative(input))\n\
.catch((error) => {{\n\
initializationPromise = undefined;\n\
throw error;\n\
}});\n\
}}\n\
return initializationPromise;\n\
}}\n\n\
export class ResourceClosedError extends globalThis.Error {{\n\
constructor(resource) {{\n\
super(`${{resource}} is closed`);\n\
this.name = \"ResourceClosedError\";\n\
}}\n\
}}\n\n",
typescript_runtime_imports(contract),
json_string(fingerprint)
);
output.push_str(deep_freeze_runtime());
for item in &manifest.types {
if let TypeShape::StringEnum { variants } = &item.shape {
output.push_str(&format!("export const {} = Object.freeze({{\n", item.name));
for variant in variants {
output.push_str(&format!(
" {}: {},\n",
variant.rust_name,
json_string(&variant.wire_name)
));
}
output.push_str("});\n\n");
}
}
for error in &manifest.errors {
output.push_str(&format!(
"export class {} extends globalThis.Error {{\n constructor(message, code) {{\n super(message);\n this.name = {};\n this.code = code;\n }}\n}}\n\n",
error.name,
json_string(&error.name)
));
}
output.push_str(
"function translateError(error, expectedType, ErrorClass, codes) {\n\
if (error instanceof WebAssembly.RuntimeError || error instanceof globalThis.Error && !ErrorClass) {\n\
return error;\n\
}\n\
const typeId = error?.typeId ?? error?.type_id;\n\
let code = error?.code;\n\
let message = error?.message;\n\
if (typeof error === \"string\") {\n\
const separator = error.indexOf(\"\\n\");\n\
code = separator < 0 ? error : error.slice(0, separator);\n\
message = separator < 0 ? error : error.slice(separator + 1);\n\
}\n\
if (ErrorClass && (typeId === expectedType || codes.includes(code))) {\n\
return new ErrorClass(message ?? String(error), code ?? \"unknown\");\n\
}\n\
return error instanceof globalThis.Error ? error : new globalThis.Error(error?.message ?? String(error));\n\
}\n\n",
);
for function in manifest
.functions
.iter()
.filter(|function| matches!(function.target, Target::Both | Target::Typescript))
{
let params = function
.params
.iter()
.map(|param| param.host_name.as_str())
.collect::<Vec<_>>();
output.push_str(&format!(
"export function {}({}) {{\n try {{\n return deepFreeze(native.{}({}));\n }} catch (error) {{\n throw {};\n }}\n}}\n\n",
function.host_name,
params.join(", "),
function.host_name,
params.join(", "),
translate_error(&function.error, contract)
));
}
for resource in manifest
.resources
.iter()
.filter(|resource| matches!(resource.target, Target::Both | Target::Typescript))
{
let constructors = resource
.constructors
.iter()
.filter(|constructor| matches!(constructor.target, Target::Both | Target::Typescript))
.collect::<Vec<_>>();
let constructor = constructors
.iter()
.copied()
.find(|constructor| constructor.rust_name == "new")
.or_else(|| constructors.first().copied());
let params = constructor
.map(|constructor| {
constructor
.params
.iter()
.map(|param| param.host_name.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
output.push_str(&format!(
"export class {} {{\n constructor({}) {{\n try {{\n this.handle = new native.__RspytsWasm{}({});\n }} catch (error) {{\n throw {};\n }}\n }}\n\n requireHandle() {{\n if (this.handle === null) throw new ResourceClosedError({});\n return this.handle;\n }}\n",
resource.name,
params.join(", "),
resource.name,
params.join(", "),
constructor
.map(|constructor| translate_error(&constructor.error, contract))
.unwrap_or_else(|| "translateError(error)".into()),
json_string(&resource.name)
));
for factory in constructors
.into_iter()
.filter(|factory| Some(*factory) != constructor)
{
let params = factory
.params
.iter()
.map(|param| param.host_name.as_str())
.collect::<Vec<_>>();
output.push_str(&format!(
"\n static {}({}) {{\n try {{\n const resource = Object.create({}.prototype);\n resource.handle = native.__RspytsWasm{}.{}({});\n return resource;\n }} catch (error) {{\n throw {};\n }}\n }}\n",
factory.host_name,
params.join(", "),
resource.name,
resource.name,
factory.host_name,
params.join(", "),
translate_error(&factory.error, contract),
));
}
for method in resource
.methods
.iter()
.filter(|method| matches!(method.target, Target::Both | Target::Typescript))
{
let params = method
.params
.iter()
.map(|param| param.host_name.as_str())
.collect::<Vec<_>>();
output.push_str(&format!(
"\n {}({}) {{\n try {{\n return deepFreeze(this.requireHandle().{}({}));\n }} catch (error) {{\n if (error instanceof WebAssembly.RuntimeError) this.free();\n throw {};\n }}\n }}\n",
method.host_name,
params.join(", "),
method.host_name,
params.join(", "),
translate_error(&method.error, contract)
));
}
output.push_str(
"\n free() {\n if (this.handle !== null) {\n this.handle.free();\n this.handle = null;\n }\n }\n\n [Symbol.dispose]() {\n this.free();\n }\n}\n\n",
);
}
for constant in manifest
.constants
.iter()
.filter(|constant| constant_in_typescript(constant.target, TypeScriptMode::Wasm))
{
output.push_str(&format!(
"export const {} = deepFreeze({});\n",
constant.host_name,
typescript_value(&constant.value, &constant.ty, contract)
));
}
output
}
fn translate_error(error_id: &Option<DefinitionId>, contract: &ResolvedContract) -> String {
match error_id
.as_ref()
.and_then(|identity| error_definition(contract, identity))
{
Some(error) => format!(
"translateError(error, {}, {}, [{}])",
json_string(
&DefinitionId {
owner: error.owner.clone(),
id: error.id.clone(),
}
.to_string()
),
error.name,
error
.variants
.iter()
.map(|variant| json_string(&variant.code))
.collect::<Vec<_>>()
.join(", ")
),
None => "translateError(error)".into(),
}
}
fn static_runtime(contract: &ResolvedContract, fingerprint: &str) -> String {
let manifest = &contract.manifest;
let mut output = format!(
"// Generated by rspyts 0.4. Do not edit.\n{}export const CONTRACT_FINGERPRINT = {};\n\n",
typescript_runtime_imports(contract),
json_string(fingerprint)
);
output.push_str(deep_freeze_runtime());
for item in &manifest.types {
if let TypeShape::StringEnum { variants } = &item.shape {
output.push_str(&format!("export const {} = Object.freeze({{\n", item.name));
for variant in variants {
output.push_str(&format!(
" {}: {},\n",
variant.rust_name,
json_string(&variant.wire_name)
));
}
output.push_str("});\n\n");
}
}
for constant in manifest
.constants
.iter()
.filter(|constant| constant_in_typescript(constant.target, TypeScriptMode::Static))
{
output.push_str(&format!(
"export const {} = deepFreeze({});\n",
constant.host_name,
typescript_value(&constant.value, &constant.ty, contract)
));
}
output
}
fn deep_freeze_runtime() -> &'static str {
"function deepFreeze(value, seen = new WeakSet()) {\n\
if (value === null || typeof value !== \"object\" || ArrayBuffer.isView(value)) return value;\n\
if (seen.has(value)) return value;\n\
seen.add(value);\n\
for (const nested of Object.values(value)) deepFreeze(nested, seen);\n\
return Object.isFrozen(value) ? value : Object.freeze(value);\n\
}\n\n"
}
fn constant_in_typescript(target: Target, mode: TypeScriptMode) -> bool {
matches!(target, Target::Both | Target::Typescript)
|| target == Target::Static && mode == TypeScriptMode::Static
}
fn typescript_type(item: &TypeDef, names: &TypeNames, contract: &ResolvedContract) -> String {
let mut output = ts_doc(item.docs.as_deref());
match &item.shape {
TypeShape::Struct { fields } => {
output.push_str(&format!("export interface {} {{\n", item.name));
for field in fields {
if let Some(docs) = field.docs.as_deref() {
output.push_str(" ");
output.push_str(ts_doc(Some(docs)).replace('\n', "\n ").trim_end());
output.push('\n');
}
output.push_str(&format!(
" readonly {}{}: {};\n",
ts_property(&field.wire_name),
if field.required { "" } else { "?" },
typescript_field_ref(field, names, contract)
));
}
output.push_str("}\n");
}
TypeShape::StringEnum { variants } => {
output.push_str(&format!("export enum {} {{\n", item.name));
for variant in variants {
output.push_str(&format!(
" {} = {},\n",
variant.rust_name,
json_string(&variant.wire_name)
));
}
output.push_str("}\n");
}
TypeShape::TaggedEnum { tag, variants } => {
output.push_str(&format!("export type {} =\n", item.name));
for (index, variant) in variants.iter().enumerate() {
output.push_str(if index == 0 { " " } else { " | " });
output.push_str(&format!(
"{{ readonly {}: {}",
ts_property(tag),
json_string(&variant.wire_name)
));
for field in &variant.fields {
output.push_str(&format!(
"; readonly {}{}: {}",
ts_property(&field.wire_name),
if field.required { "" } else { "?" },
typescript_field_ref(field, names, contract)
));
}
output.push_str(" }\n");
}
output.push_str(";\n");
}
TypeShape::Alias { target } => output.push_str(&format!(
"export type {} = {};\n",
item.name,
typescript_ref(target, names)
)),
}
output
}
fn typescript_field_ref(
field: &FieldDef,
names: &TypeNames,
contract: &ResolvedContract,
) -> String {
match &field.constraints.literal {
Some(value) => {
let literal = typescript_scalar(value, &field.ty, contract);
if matches!(field.ty, TypeRef::Option { .. }) {
format!("{literal} | null")
} else {
literal
}
}
None => typescript_ref(&field.ty, names),
}
}
fn typescript_ref(reference: &TypeRef, names: &TypeNames) -> String {
match reference {
TypeRef::Unit => "void".into(),
TypeRef::Bool => "boolean".into(),
TypeRef::Int { bits: 64, .. } => "bigint".into(),
TypeRef::Int { .. } | TypeRef::Float { .. } => "number".into(),
TypeRef::String => "string".into(),
TypeRef::DateTime => "string".into(),
TypeRef::Json => "JsonValue".into(),
TypeRef::Option { item } => format!("{} | null", typescript_ref(item, names)),
TypeRef::List { item } => format!("ReadonlyArray<{}>", typescript_ref(item, names)),
TypeRef::Map { value } => {
format!("Readonly<Record<string, {}>>", typescript_ref(value, names))
}
TypeRef::Tuple { items } => format!(
"readonly [{}]",
items
.iter()
.map(|item| typescript_ref(item, names))
.collect::<Vec<_>>()
.join(", ")
),
TypeRef::Named { identity } => names
.get(identity)
.cloned()
.unwrap_or_else(|| identity.to_string()),
TypeRef::Bytes => "Uint8Array".into(),
TypeRef::Buffer { element } => match element {
BufferElement::U8 => "Uint8Array",
BufferElement::I8 => "Int8Array",
BufferElement::U16 => "Uint16Array",
BufferElement::I16 => "Int16Array",
BufferElement::U32 => "Uint32Array",
BufferElement::I32 => "Int32Array",
BufferElement::U64 => "BigUint64Array",
BufferElement::I64 => "BigInt64Array",
BufferElement::F32 => "Float32Array",
BufferElement::F64 => "Float64Array",
}
.into(),
}
}
fn typescript_literal_type(
value: &Value,
ty: &TypeRef,
contract: &ResolvedContract,
names: &TypeNames,
) -> String {
match (value, ty) {
(Value::Null, _) => "null".into(),
(value, TypeRef::Option { item }) => typescript_literal_type(value, item, contract, names),
(value, TypeRef::Named { identity }) => match type_definition(contract, identity) {
Some(TypeDef {
name,
shape: TypeShape::StringEnum { variants },
..
}) => value
.as_str()
.and_then(|wire| {
variants
.iter()
.find(|variant| variant.wire_name == wire)
.map(|variant| format!("{name}.{}", variant.rust_name))
})
.unwrap_or_else(|| typescript_ref(ty, names)),
Some(TypeDef {
shape: TypeShape::Alias { target },
..
}) => typescript_literal_type(value, target, contract, names),
Some(TypeDef {
shape: TypeShape::Struct { fields },
..
}) => match value {
Value::Object(values) => {
typescript_struct_literal_type(values, fields, contract, names)
}
_ => typescript_ref(ty, names),
},
Some(TypeDef {
shape: TypeShape::TaggedEnum { tag, variants },
..
}) => match value {
Value::Object(values) => {
let variant = values
.get(tag)
.and_then(Value::as_str)
.and_then(|wire| variants.iter().find(|variant| variant.wire_name == wire));
variant
.map(|variant| {
let fields = std::iter::once(rspyts::ir::FieldDef {
rust_name: tag.clone(),
wire_name: tag.clone(),
docs: None,
ty: TypeRef::String,
required: true,
default: None,
constraints: rspyts::ir::FieldConstraints::default(),
})
.chain(variant.fields.iter().cloned())
.collect::<Vec<_>>();
typescript_struct_literal_type(values, &fields, contract, names)
})
.unwrap_or_else(|| typescript_ref(ty, names))
}
_ => typescript_ref(ty, names),
},
None => typescript_ref(ty, names),
},
(Value::Bool(value), _) => value.to_string(),
(Value::Number(value), TypeRef::Int { bits: 64, .. }) => format!("{value}n"),
(Value::Number(value), _) => value.to_string(),
(Value::String(value), _) => json_string(value),
(Value::Array(values), TypeRef::List { item }) => format!(
"readonly [{}]",
values
.iter()
.map(|value| typescript_literal_type(value, item, contract, names))
.collect::<Vec<_>>()
.join(", ")
),
(Value::Array(values), TypeRef::Tuple { items }) => format!(
"readonly [{}]",
values
.iter()
.zip(items)
.map(|(value, item)| typescript_literal_type(value, item, contract, names))
.collect::<Vec<_>>()
.join(", ")
),
(Value::Object(values), TypeRef::Map { value }) => format!(
"{{ {} }}",
values
.iter()
.map(|(key, item)| format!(
"readonly {}: {}",
json_string(key),
typescript_literal_type(item, value, contract, names)
))
.collect::<Vec<_>>()
.join("; ")
),
(Value::Array(_), TypeRef::Bytes | TypeRef::Buffer { .. }) => typescript_ref(ty, names),
_ => typescript_json_literal_type(value, contract, names),
}
}
fn typescript_scalar(value: &ScalarValue, ty: &TypeRef, contract: &ResolvedContract) -> String {
if let TypeRef::Option { item } = ty {
return typescript_scalar(value, item, contract);
}
if let TypeRef::Named { identity } = ty {
return match type_definition(contract, identity) {
Some(TypeDef {
name,
shape: TypeShape::StringEnum { variants },
..
}) => match value {
ScalarValue::String(value) => variants
.iter()
.find(|variant| variant.wire_name == *value)
.map(|variant| format!("{name}.{}", variant.rust_name))
.unwrap_or_else(|| json_string(value)),
_ => typescript_scalar_value(value, ty),
},
Some(TypeDef {
shape: TypeShape::Alias { target },
..
}) => typescript_scalar(value, target, contract),
_ => typescript_scalar_value(value, ty),
};
}
typescript_scalar_value(value, ty)
}
fn typescript_scalar_value(value: &ScalarValue, ty: &TypeRef) -> String {
match value {
ScalarValue::Bool(value) => value.to_string(),
ScalarValue::I64(value) if matches!(ty, TypeRef::Int { bits: 64, .. }) => {
format!("{value}n")
}
ScalarValue::I64(value) => value.to_string(),
ScalarValue::String(value) => json_string(value),
}
}
fn typescript_struct_literal_type(
values: &serde_json::Map<String, Value>,
fields: &[rspyts::ir::FieldDef],
contract: &ResolvedContract,
names: &TypeNames,
) -> String {
format!(
"{{ {} }}",
fields
.iter()
.filter_map(|field| {
values.get(&field.wire_name).map(|value| {
format!(
"readonly {}: {}",
json_string(&field.wire_name),
typescript_literal_type(value, &field.ty, contract, names)
)
})
})
.collect::<Vec<_>>()
.join("; ")
)
}
fn typescript_json_literal_type(
value: &Value,
contract: &ResolvedContract,
names: &TypeNames,
) -> String {
match value {
Value::Null => "null".into(),
Value::Bool(value) => value.to_string(),
Value::Number(value) => value.to_string(),
Value::String(value) => json_string(value),
Value::Array(values) => format!(
"readonly [{}]",
values
.iter()
.map(|value| typescript_literal_type(value, &TypeRef::Json, contract, names))
.collect::<Vec<_>>()
.join(", ")
),
Value::Object(values) => format!(
"{{ {} }}",
values
.iter()
.map(|(key, value)| format!(
"readonly {}: {}",
json_string(key),
typescript_literal_type(value, &TypeRef::Json, contract, names)
))
.collect::<Vec<_>>()
.join("; ")
),
}
}
fn typescript_value(value: &Value, ty: &TypeRef, contract: &ResolvedContract) -> String {
match (value, ty) {
(Value::Number(number), TypeRef::Int { bits: 64, .. }) => format!("{number}n"),
(Value::Array(values), TypeRef::List { item }) => format!(
"[{}]",
values
.iter()
.map(|value| typescript_value(value, item, contract))
.collect::<Vec<_>>()
.join(", ")
),
(Value::Array(values), TypeRef::Tuple { items }) => format!(
"[{}]",
values
.iter()
.zip(items)
.map(|(value, item)| typescript_value(value, item, contract))
.collect::<Vec<_>>()
.join(", ")
),
(Value::Object(values), TypeRef::Map { value }) => format!(
"{{ {} }}",
values
.iter()
.map(|(key, item)| format!(
"{}: {}",
json_string(key),
typescript_value(item, value, contract)
))
.collect::<Vec<_>>()
.join(", ")
),
(value, TypeRef::Option { item }) if !value.is_null() => {
typescript_value(value, item, contract)
}
(value, TypeRef::Named { identity }) => match type_definition(contract, identity) {
Some(TypeDef {
shape: TypeShape::Alias { target },
..
}) => typescript_value(value, target, contract),
Some(TypeDef {
shape: TypeShape::Struct { fields },
..
}) => match value.as_object() {
Some(values) => format!(
"{{ {} }}",
fields
.iter()
.filter_map(|field| {
values.get(&field.wire_name).map(|value| {
format!(
"{}: {}",
ts_property(&field.wire_name),
typescript_value(value, &field.ty, contract)
)
})
})
.collect::<Vec<_>>()
.join(", ")
),
None => serde_json::to_string(value).expect("JSON value is serializable"),
},
Some(TypeDef {
shape: TypeShape::TaggedEnum { tag, variants },
..
}) => match value.as_object() {
Some(values) => {
let variant = values
.get(tag)
.and_then(Value::as_str)
.and_then(|tag| variants.iter().find(|variant| variant.wire_name == tag));
match variant {
Some(variant) => format!(
"{{ {} }}",
std::iter::once(format!(
"{}: {}",
ts_property(tag),
json_string(&variant.wire_name)
))
.chain(variant.fields.iter().filter_map(|field| {
values.get(&field.wire_name).map(|value| {
format!(
"{}: {}",
ts_property(&field.wire_name),
typescript_value(value, &field.ty, contract)
)
})
}))
.collect::<Vec<_>>()
.join(", ")
),
None => serde_json::to_string(value).expect("JSON value is serializable"),
}
}
None => serde_json::to_string(value).expect("JSON value is serializable"),
},
_ => serde_json::to_string(value).expect("JSON value is serializable"),
},
(Value::Array(values), TypeRef::Bytes) => format!(
"new Uint8Array([{}])",
values
.iter()
.map(Value::to_string)
.collect::<Vec<_>>()
.join(", ")
),
(Value::Array(values), TypeRef::Buffer { element }) => format!(
"new {}([{}])",
match element {
BufferElement::U8 => "Uint8Array",
BufferElement::I8 => "Int8Array",
BufferElement::U16 => "Uint16Array",
BufferElement::I16 => "Int16Array",
BufferElement::U32 => "Uint32Array",
BufferElement::I32 => "Int32Array",
BufferElement::U64 => "BigUint64Array",
BufferElement::I64 => "BigInt64Array",
BufferElement::F32 => "Float32Array",
BufferElement::F64 => "Float64Array",
},
values
.iter()
.map(|value| {
if matches!(element, BufferElement::U64 | BufferElement::I64) {
format!("{value}n")
} else {
value.to_string()
}
})
.collect::<Vec<_>>()
.join(", ")
),
_ => serde_json::to_string(value).expect("JSON value is serializable"),
}
}
fn json_string(value: &str) -> String {
serde_json::to_string(value).expect("a string is serializable")
}
fn typescript_declaration_imports(contract: &ResolvedContract) -> String {
let mut output = String::new();
for dependency in contract.dependencies.values() {
let Some(package) = dependency.typescript.as_deref() else {
continue;
};
let type_only = contract
.foreign_types
.iter()
.filter(|(identity, definition)| {
identity.owner == dependency.owner
&& !matches!(definition.shape, TypeShape::StringEnum { .. })
})
.map(|(_, definition)| definition.name.as_str())
.collect::<Vec<_>>();
let enums = contract
.foreign_types
.iter()
.filter(|(identity, definition)| {
identity.owner == dependency.owner
&& matches!(definition.shape, TypeShape::StringEnum { .. })
})
.map(|(_, definition)| definition.name.as_str())
.collect::<Vec<_>>();
let errors = contract
.foreign_errors
.iter()
.filter(|(identity, _)| identity.owner == dependency.owner)
.map(|(_, definition)| definition.name.as_str())
.collect::<Vec<_>>();
if !type_only.is_empty() {
output.push_str(&format!(
"import type {{ {} }} from {};\nexport type {{ {} }} from {};\n",
type_only.join(", "),
json_string(package),
type_only.join(", "),
json_string(package)
));
}
if !enums.is_empty() {
output.push_str(&format!(
"import {{ {} }} from {};\nexport {{ {} }} from {};\n",
enums.join(", "),
json_string(package),
enums.join(", "),
json_string(package)
));
}
if !errors.is_empty() {
output.push_str(&format!(
"import {{ {} }} from {};\nexport {{ {} }} from {};\n",
errors.join(", "),
json_string(package),
errors.join(", "),
json_string(package)
));
}
}
if !output.is_empty() {
output.push('\n');
}
output
}
fn typescript_runtime_imports(contract: &ResolvedContract) -> String {
let mut output = String::new();
for dependency in contract.dependencies.values() {
let Some(package) = dependency.typescript.as_deref() else {
continue;
};
let errors = contract
.foreign_errors
.iter()
.filter(|(identity, _)| identity.owner == dependency.owner)
.map(|(_, definition)| definition.name.as_str())
.collect::<Vec<_>>();
if !errors.is_empty() {
output.push_str(&format!(
"import {{ {} }} from {};\nexport {{ {} }};\n",
errors.join(", "),
json_string(package),
errors.join(", ")
));
}
let enums = contract
.foreign_types
.iter()
.filter(|(identity, definition)| {
identity.owner == dependency.owner
&& matches!(definition.shape, TypeShape::StringEnum { .. })
})
.map(|(_, definition)| definition.name.as_str())
.collect::<Vec<_>>();
if !enums.is_empty() {
output.push_str(&format!(
"export {{ {} }} from {};\n",
enums.join(", "),
json_string(package)
));
}
}
output
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use rspyts::ir::*;
use super::*;
fn resolved(manifest: Manifest, mode: TypeScriptMode) -> ResolvedContract {
ResolvedContract {
manifest,
hosts: crate::LockedHosts {
python: None,
typescript: Some(crate::LockedTypeScriptHost {
package: "sample".into(),
mode,
}),
},
dependencies: BTreeMap::new(),
foreign_types: BTreeMap::new(),
foreign_errors: BTreeMap::new(),
}
}
#[test]
fn emits_exact_json_and_wide_integer_types() {
let owner = CargoPackageId::new("sample");
let manifest = Manifest {
ir_version: 4,
crate_name: "sample".into(),
crate_version: "1.0.0".into(),
module_name: "native".into(),
imports: vec![],
types: vec![],
errors: vec![],
functions: vec![FunctionDef {
owner,
rust_name: "read".into(),
host_name: "read".into(),
docs: None,
target: Target::Typescript,
params: vec![ParamDef {
rust_name: "value".into(),
host_name: "value".into(),
ty: TypeRef::Json,
}],
returns: TypeRef::Int {
signed: false,
bits: 64,
},
error: None,
}],
resources: vec![],
constants: vec![],
};
let contract = resolved(manifest, TypeScriptMode::Wasm);
let generated = declarations(
&contract,
"sha256:test",
&type_names(&contract),
TypeScriptMode::Wasm,
);
assert!(
generated.contains(
"type JsonValue = null | boolean | number | string | readonly JsonValue[]"
)
);
assert!(generated.contains("read(value: JsonValue): bigint"));
assert!(!generated.contains("unknown"));
}
#[test]
fn emits_datetime_literals_defaults_and_readonly_fields() {
let owner = CargoPackageId::new("sample");
let manifest = Manifest {
ir_version: 4,
crate_name: "sample".into(),
crate_version: "1.0.0".into(),
module_name: "native".into(),
imports: vec![],
types: vec![TypeDef {
owner,
id: "sample::Batch".into(),
name: "Batch".into(),
docs: None,
shape: TypeShape::Struct {
fields: vec![
FieldDef {
rust_name: "contract_version".into(),
wire_name: "contractVersion".into(),
docs: None,
ty: TypeRef::Int {
signed: false,
bits: 32,
},
required: true,
default: None,
constraints: FieldConstraints {
literal: Some(ScalarValue::I64(2)),
..Default::default()
},
},
FieldDef {
rust_name: "observed_at".into(),
wire_name: "observedAt".into(),
docs: None,
ty: TypeRef::DateTime,
required: true,
default: None,
constraints: Default::default(),
},
FieldDef {
rust_name: "quantity".into(),
wire_name: "quantity".into(),
docs: None,
ty: TypeRef::Int {
signed: false,
bits: 32,
},
required: false,
default: Some(ScalarValue::I64(1)),
constraints: FieldConstraints {
ge: Some(1),
..Default::default()
},
},
],
},
}],
errors: vec![],
functions: vec![],
resources: vec![],
constants: vec![],
};
let contract = resolved(manifest, TypeScriptMode::Static);
let generated = declarations(
&contract,
"sha256:test",
&type_names(&contract),
TypeScriptMode::Static,
);
assert!(generated.contains("readonly contractVersion: 2;"));
assert!(generated.contains("readonly observedAt: string;"));
assert!(generated.contains("readonly quantity?: number;"));
}
#[test]
fn declarations_are_deeply_readonly() {
let owner = CargoPackageId::new("sample");
let manifest = Manifest {
ir_version: 4,
crate_name: "sample".into(),
crate_version: "1.0.0".into(),
module_name: "native".into(),
imports: vec![],
types: vec![TypeDef {
owner,
id: "sample::Nested".into(),
name: "Nested".into(),
docs: None,
shape: TypeShape::Struct {
fields: vec![
FieldDef {
rust_name: "items".into(),
wire_name: "items".into(),
docs: None,
ty: TypeRef::List {
item: Box::new(TypeRef::Map {
value: Box::new(TypeRef::Tuple {
items: vec![TypeRef::String, TypeRef::Json],
}),
}),
},
required: true,
default: None,
constraints: Default::default(),
},
FieldDef {
rust_name: "bytes".into(),
wire_name: "bytes".into(),
docs: None,
ty: TypeRef::Bytes,
required: true,
default: None,
constraints: Default::default(),
},
],
},
}],
errors: vec![],
functions: vec![],
resources: vec![],
constants: vec![],
};
let contract = resolved(manifest, TypeScriptMode::Static);
let generated = declarations(
&contract,
"sha256:test",
&type_names(&contract),
TypeScriptMode::Static,
);
assert!(generated.contains(
"readonly items: ReadonlyArray<Readonly<Record<string, readonly [string, JsonValue]>>>;"
));
assert!(generated.contains("readonly bytes: Uint8Array;"));
assert!(generated.contains("readonly JsonValue[]"));
assert!(generated.contains("{ readonly [key: string]: JsonValue }"));
}
#[test]
fn static_runtime_deeply_freezes_values_but_not_typed_arrays() {
if !Command::new("node")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success())
{
return;
}
let owner = CargoPackageId::new("sample");
let manifest = Manifest {
ir_version: 4,
crate_name: "sample".into(),
crate_version: "1.0.0".into(),
module_name: "native".into(),
imports: vec![],
types: vec![],
errors: vec![],
functions: vec![],
resources: vec![],
constants: vec![
ConstantDef {
owner: owner.clone(),
rust_name: "CONFIG".into(),
host_name: "CONFIG".into(),
docs: None,
target: Target::Static,
ty: TypeRef::Json,
value: serde_json::json!({
"nested": {"items": [1, {"ready": true}]}
}),
},
ConstantDef {
owner,
rust_name: "BYTES".into(),
host_name: "BYTES".into(),
docs: None,
target: Target::Static,
ty: TypeRef::Bytes,
value: serde_json::json!([1, 2, 3]),
},
],
};
let contract = resolved(manifest, TypeScriptMode::Static);
let mut generated = static_runtime(&contract, "sha256:test");
generated.push_str(
"\nconst shallow = Object.freeze({ nested: { mutable: true } });\n\
deepFreeze(shallow);\n\
if (!Object.isFrozen(CONFIG) || !Object.isFrozen(CONFIG.nested) || \
!Object.isFrozen(CONFIG.nested.items) || \
!Object.isFrozen(CONFIG.nested.items[1])) throw new Error('not deeply frozen');\n\
if (!Object.isFrozen(shallow.nested)) throw new Error('shallow parent skipped');\n\
if (Object.isFrozen(BYTES)) throw new Error('typed array was frozen');\n",
);
let root = std::env::temp_dir().join(format!(
"rspyts-typescript-freeze-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
let module = root.join("generated.mjs");
fs::write(&module, generated).unwrap();
let result = Command::new("node")
.arg(&module)
.output()
.expect("Node is required to verify generated TypeScript runtime behavior");
assert!(
result.status.success(),
"generated runtime did not deeply freeze values:\n{}{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn constants_are_target_filtered_and_enum_typed_in_both_modes() {
let owner = CargoPackageId::new("sample");
let status_identity = DefinitionId::new(owner.as_str(), "sample::Status");
let constant = |name: &str, target, value: &str| ConstantDef {
owner: owner.clone(),
rust_name: name.into(),
host_name: name.into(),
docs: None,
target,
ty: TypeRef::Named {
identity: status_identity.clone(),
},
value: Value::String(value.into()),
};
let manifest = Manifest {
ir_version: 4,
crate_name: "sample".into(),
crate_version: "1.0.0".into(),
module_name: "native".into(),
imports: vec![],
types: vec![TypeDef {
owner: owner.clone(),
id: status_identity.id.clone(),
name: "Status".into(),
docs: None,
shape: TypeShape::StringEnum {
variants: vec![EnumVariantDef {
rust_name: "Ready".into(),
wire_name: "ready".into(),
docs: None,
fields: vec![],
}],
},
}],
errors: vec![],
functions: vec![],
resources: vec![],
constants: vec![
constant("TYPESCRIPT_STATUS", Target::Typescript, "ready"),
constant("SHARED_STATUS", Target::Both, "ready"),
constant("STATIC_STATUS", Target::Static, "ready"),
constant("PYTHON_STATUS", Target::Python, "ready"),
],
};
let contract = resolved(manifest, TypeScriptMode::Wasm);
let declarations = declarations(
&contract,
"sha256:test",
&type_names(&contract),
TypeScriptMode::Wasm,
);
let wasm = wasm_runtime(&contract, "sha256:test");
let static_js = static_runtime(&contract, "sha256:test");
assert!(declarations.contains("export const TYPESCRIPT_STATUS: Status.Ready;"));
assert!(declarations.contains("export const SHARED_STATUS: Status.Ready;"));
assert!(!declarations.contains("STATIC_STATUS"));
assert!(!declarations.contains("PYTHON_STATUS"));
assert!(wasm.contains("export const TYPESCRIPT_STATUS = deepFreeze(\"ready\");"));
assert!(wasm.contains("export const SHARED_STATUS = deepFreeze(\"ready\");"));
assert!(!wasm.contains("STATIC_STATUS"));
assert!(!wasm.contains("PYTHON_STATUS"));
assert!(static_js.contains("export const TYPESCRIPT_STATUS = deepFreeze(\"ready\");"));
assert!(static_js.contains("export const SHARED_STATUS = deepFreeze(\"ready\");"));
assert!(static_js.contains("export const STATIC_STATUS = deepFreeze(\"ready\");"));
assert!(!static_js.contains("PYTHON_STATUS"));
}
#[test]
fn contract_error_named_error_never_shadows_javascript_error() {
let owner = CargoPackageId::new("sample");
let manifest = Manifest {
ir_version: 4,
crate_name: "sample".into(),
crate_version: "1.0.0".into(),
module_name: "native".into(),
imports: vec![],
types: vec![],
errors: vec![ErrorDef {
owner,
id: "sample::Error".into(),
name: "Error".into(),
docs: None,
variants: vec![],
}],
functions: vec![],
resources: vec![],
constants: vec![],
};
let contract = resolved(manifest, TypeScriptMode::Wasm);
let declarations = declarations(
&contract,
"sha256:test",
&type_names(&contract),
TypeScriptMode::Wasm,
);
let runtime = wasm_runtime(&contract, "sha256:test");
assert!(declarations.contains("class Error extends globalThis.Error"));
assert!(runtime.contains("class Error extends globalThis.Error"));
assert!(runtime.contains("error instanceof globalThis.Error"));
assert!(runtime.contains("new globalThis.Error("));
assert!(!runtime.contains("class Error extends Error"));
}
#[test]
fn wasm_runtime_deeply_freezes_function_and_method_results() {
let owner = CargoPackageId::new("sample");
let runner_identity = DefinitionId::new(owner.as_str(), "sample::Runner");
let manifest = Manifest {
ir_version: 4,
crate_name: "sample".into(),
crate_version: "1.0.0".into(),
module_name: "native".into(),
imports: vec![],
types: vec![],
errors: vec![],
functions: vec![FunctionDef {
owner: owner.clone(),
rust_name: "snapshot".into(),
host_name: "snapshot".into(),
docs: None,
target: Target::Typescript,
params: vec![],
returns: TypeRef::Json,
error: None,
}],
resources: vec![ResourceDef {
owner: owner.clone(),
id: runner_identity.id.clone(),
name: "Runner".into(),
docs: None,
target: Target::Typescript,
constructors: vec![FunctionDef {
owner,
rust_name: "new".into(),
host_name: "new".into(),
docs: None,
target: Target::Typescript,
params: vec![],
returns: TypeRef::Named {
identity: runner_identity,
},
error: None,
}],
methods: vec![MethodDef {
rust_name: "snapshot".into(),
host_name: "snapshot".into(),
docs: None,
target: Target::Typescript,
mutable: false,
params: vec![],
returns: TypeRef::Json,
error: None,
}],
}],
constants: vec![],
};
let contract = resolved(manifest, TypeScriptMode::Wasm);
let runtime = wasm_runtime(&contract, "sha256:test");
assert!(runtime.contains("return deepFreeze(native.snapshot());"));
assert!(runtime.contains("return deepFreeze(this.requireHandle().snapshot());"));
assert!(runtime.contains("ArrayBuffer.isView(value)"));
assert!(runtime.contains("for (const nested of Object.values(value))"));
}
#[test]
fn wasm_init_coalesces_concurrent_calls_and_retries_after_rejection() {
if !Command::new("node")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success())
{
return;
}
let contract = resolved(
Manifest {
ir_version: 4,
crate_name: "sample".into(),
crate_version: "1.0.0".into(),
module_name: "native".into(),
imports: vec![],
types: vec![],
errors: vec![],
functions: vec![],
resources: vec![],
constants: vec![],
},
TypeScriptMode::Wasm,
);
let runtime = wasm_runtime(&contract, "sha256:test");
assert!(runtime.contains("let initializationPromise;"));
assert!(runtime.contains("export default function init(input)"));
assert!(!runtime.contains("export default async function"));
assert!(runtime.contains("initializationPromise = undefined;"));
let root = std::env::temp_dir().join(format!(
"rspyts-typescript-init-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
fs::write(root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
fs::write(root.join("index.js"), runtime).unwrap();
fs::write(
root.join("native.js"),
"export let calls = 0;\n\
export default function initialize(input) {\n\
calls += 1;\n\
if (input === 'fail') return Promise.reject(new Error('boom'));\n\
return Promise.resolve({ call: calls });\n\
}\n",
)
.unwrap();
fs::write(
root.join("acceptance.js"),
"import init from './index.js';\n\
import { calls } from './native.js';\n\
const first = init('fail');\n\
const concurrent = init('ignored');\n\
if (!(first instanceof Promise) || first !== concurrent) \
throw new Error('concurrent calls did not share one Promise');\n\
await first.then(\n\
() => { throw new Error('expected initialization rejection'); },\n\
() => undefined,\n\
);\n\
if (calls !== 1) throw new Error(`expected one first attempt, received ${calls}`);\n\
const retry = init('ok');\n\
if (retry === first) throw new Error('rejection did not clear cached Promise');\n\
const result = await retry;\n\
if (result.call !== 2 || calls !== 2) throw new Error('retry did not initialize once');\n\
if (init('ignored-again') !== retry) throw new Error('success was not cached');\n\
if (calls !== 2) throw new Error('cached success initialized again');\n",
)
.unwrap();
let result = Command::new("node")
.arg(root.join("acceptance.js"))
.output()
.expect("Node is required to verify generated TypeScript initialization");
assert!(
result.status.success(),
"generated WASM init cache failed:\n{}{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
fs::remove_dir_all(root).unwrap();
}
}