Tauri v2 IPCはRustとTypeScriptの契約を明示してずれを防ぐ
Tauri v2では、TypeScript frontendからRust commandを呼ぶのは簡単です。危険なのは呼び出しそのものではありません。Rust field名、JSON field名、TypeScript interface、optional引数、path文字列が自然に揃い続けると思い込むことです。
揃いません。Tauri IPC境界はserialization boundaryです。同じrepository内でもAPI契約として扱います。
症状: Rustはdataを返したのにTypeScriptではundefined
よくある失敗です。
#[derive(Debug, serde::Serialize)]
pub struct AnalysisResult {
pub source_file_count: usize,
pub total_bytes: u64,
}
type AnalysisResult = {
sourceFileCount: number;
totalBytes: number;
};
Rust commandは正しいobjectを返します。しかしUIが result.sourceFileCount を読むと undefined になります。TypeScriptはruntime JSON shapeを検証しないため、型注釈では気づけません。
実際のJSONはこうです。
{
"source_file_count": 12,
"total_bytes": 94012
}
frontendはcamelCaseを期待し、Rustはsnake_caseでserializeしました。
IPC structにはserde namingを付ける
Tauri境界を越えるstructには、明示的なserde naming ruleを付けます。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisResult {
pub source_file_count: usize,
pub total_bytes: u64,
}
これでRustはこうserializeします。
{
"sourceFileCount": 12,
"totalBytes": 94012
}
Rust structがTypeScriptへ送られる、またはTypeScriptから受け取るなら、serialization shapeを明示します。
command parameterも意図的に合わせる
Tauri command引数でも命名がずれます。
#[tauri::command]
pub fn calculate_crc(input: String, preset_name: Option<String>) -> Result<String, String> {
Ok(format!("{input}:{preset_name:?}"))
}
commandが期待するshapeで呼びます。
await invoke<string>("calculate_crc", {
input: "123456789",
preset_name: null,
});
camelCaseにしたいならrequest structに包み、そこに #[serde(rename_all = "camelCase")] を付けます。
optional値はundefinedではなくnullを送る
JavaScriptの undefined は「field omitted」になりがちです。Rustの Option<T> には、値がないことを null で明示します。
await invoke("load_project", {
projectId: selectedProjectId ?? null,
});
undefined のままだとkeyがpayloadから消え、Rust deserializationが失敗したり、別のshapeに見えたりします。
大きな数値はstringで渡す
JavaScript numberはすべての u64 や u128 を正確に表せません。identifier、memory address、checksum、timestamp、byte offset、bit maskのように Number.MAX_SAFE_INTEGER を超える可能性がある値はstringにします。
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HashResult {
pub hex: String,
pub decimal: String,
}
frontendで必要なら BigInt に変換します。
pathは境界でnormalizeする
Windows pathはbackslashを含み、JSON stringではescapeされます。browser codeはforward slashを期待することが多いです。
IPC-facing fieldではnormalizeします。
pub fn from_path(path: impl AsRef<std::path::Path>) -> Self {
Self {
path: path.as_ref().to_string_lossy().replace('\\', "/"),
}
}
内部では PathBuf を使い、IPCではbrowser-friendlyな String を渡します。
変換はservice layerに集める
componentがRust response shapeを直接parseしないようにします。
function mapPreset(raw: RustPreset): Preset {
return {
name: raw.presetName,
width: raw.widthBits,
check: BigInt(raw.checkHex),
};
}
export async function listPresets(): Promise<Preset[]> {
const raw = await invoke<RustPreset[]>("list_presets");
return raw.map(mapPreset);
}
Rustが変わった時に直す場所、testする場所、runtime validationを足す場所が1つになります。
検証チェック
- IPC request/response structに明示的なserde namingがある
- optional valueは
undefinedではなくnull - JS safe integerを超える可能性がある値はstring
- Windows pathはTypeScriptへ渡す前にnormalize
- componentが直接
invoke()だらけになっていない - Rust command outputとTypeScript mappingを比べるtestまたはsmoke runがある