Skip to main content

ラップした内部依存(CommandTransport)を外から完全に隠蔽する

TL;DR​

  • まず「隠したい外側」がどこかを決める。コンポジションルート(依存を組み立てる唯一の場所)が内部依存を知るのは正しい。
  • コンポジションルートからも消したいなら、組み立てごとファサード(TauriPorts.create() / createNull())に押し込める(Parameterless Instantiation)。createNull の設定は port の言葉で受け取り、内部で IPC のレスポンスに分解する(Fake It Once You Make It)。
  • 物理的に塞ぐなら、adapter 群を別パッケージにして package.json の exports で公開面を絞る。
  • 隠蔽は推移的にかかる。公開面からたどれる型・シグネチャのどこにも内部依存が出てはいけない。
  • 型とコンパニオンが同名だと export { X } で両方漏れる。export type { X } で型だけ公開する。
  • 漏れの検出は API Extractor の ae-forgotten-export で機械化できる。

遭遇した問題​

  • 環境: Tauri v2 のフロントエンド(TypeScript)。invoke を CommandTransport でラップし、各 port(AppWindow, DocumentIpc など)が create(transport: CommandTransport) で受け取る構成。
  • コンポジションルートでは 1 つの CommandTransport を全 port で共有している。
const transport = CommandTransport.create();
const ports = {
ipc: DocumentIpc.create(transport),
appWindow: AppWindow.create(transport),
// ...
} as const;
  • CommandTransport 型を外から見えないようにしたいが、create の引数に型が出ているため、どこまで隠れているのか曖昧だった。

原因​

  • 依存を引数で受け取るファクトリは、そのシグネチャ自体が公開面になる。AppWindow.create(transport) を外から呼べる限り、CommandTransport は見えている。
  • TypeScript には「パッケージ内だけ公開」がない。ファイルから export した時点で、どこからでも import できる。
  • コンパニオンオブジェクトを型と同名にしていると、export { AppWindow } で型と値(create(transport) を持つ)が一緒に出ていく。
  • Rust 側コマンド名の型(AppWindowCommand)のような IPC の約束事も、export すれば内部事情の漏れになる。

解決​

隠蔽レベルを選ぶ​

レベルやること向いている場面
1. コンポジションルートだけが知る今の構成のまま + ESLint no-restricted-importsアプリ内に閉じたコード。軽い
2. ファサードで組み立てごと隠すTauriPorts.create() / createNull()コンポジションルートからも消したい
3. 物理的に import 不可にする別パッケージ + exports + export type「完全に」隠したい

2. ファサードに組み立てを押し込める​

export const TauriPorts = {
create(): Ports {
return build(CommandTransport.create(), TauriDialog.create());
},
createNull(options: NullOptions = {}): Ports {
return build(
CommandTransport.createNull({
...AppWindow.nullResponses(options.appWindow), // 各 port が自分のコマンド名に分解する
}),
TauriDialog.createNull(options.dialog),
);
},
} as const;

// コンポジションルート
const ports = TauriPorts.create();
  • 1 インスタンス共有は build の中で保たれる。
  • テストでは TauriPorts.createNull({ appWindow: { closeError: "window not found" } }) のように port の振る舞いで書き、コマンド名への分解は各 port(AppWindow.nullResponses など)が持つ。port 単体の AppWindow.createNull() も同じ分解を使えばよい。

3. 別パッケージ + exports + export type​

{
"name": "@app/tauri-adapters",
"exports": { ".": "./src/index.ts" }
}
// src/index.ts(唯一の公開面)
export { TauriPorts } from "./tauri-ports";
export type { Ports } from "./tauri-ports";
export type { AppWindow, AppWindowError } from "./app-window";
// AppWindow のコンパニオン(create(transport))・AppWindowCommand・CommandTransport は出さない
  • exports に書いていないファイルはモジュール解決で拒否される。TypeScript も moduleResolution を bundler / nodenext にすれば型レベルで import できなくなる。
  • CommandTransport や各 port 単体のテストはパッケージの内側に置くので、そこからは普通に使える。
  • コンパニオンに IPC と無関係な純粋関数が同居していて外から使いたい場合は、別のコンパニオン(または名前付き関数)に分ける。「口を作る責務」と「値を扱う責務」が同居していると片方だけ公開できない。

公開面チェックリスト​

外から見えるもの判定
port の型(close(): Promise<Result<void, AppWindowError>>)OK(Tauri を知らない)
エラー型({ message })OK(生の Tauri エラーを詰めない限り)
コンパニオンの create(transport)NG → export type で型だけ公開
コマンド名の型NG → export しない

ESLint は「うっかり」を防ぐが外せば破れる。exports は入口の封鎖、API Extractor の ae-forgotten-export は「公開した型の中身から非公開の型を参照している」漏れの検出、と役割が違う。

コスト​

  • レベル 2 以降は port が増えるたびに NullOptions と各 port の nullResponses を育てる必要がある。Nullables はテストのための本番コードを持つ、というトレードオフを受け入れる設計。
  • 変換コードを書きたくなければ、使う側のテストは Ports の各 port を手書き fake で差し替え、adapter 側はパッケージ内で mockIPC を使う。そうすれば TauriPorts は create() だけで済む。

まとめ​

  • 隠蔽は「どこから見えないか」を決めてから選ぶ。完全に隠すなら、ファサードで組み立てを閉じ込め、exports で入口を塞ぎ、export type で公開面からたどれる型をきれいに保つ。

参考​