← ./articles-ja

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はすべての u64u128 を正確に表せません。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がある

参考