はじめに
Kotlin Compose for Web で NBA トレード速報アプリを開発しています。バックエンドは Rust (Axum + async-graphql) で、Ktor HttpClient 経由で GraphQL 通信を行っています。
フロントエンド単体で開発・動作確認をしたくて、JavaScript エコシステムの MSW (Mock Service Worker) v2 を Kotlin/JS から使う方法を模索しました。
MSW は Service Worker レベルで HTTP リクエストをインターセプトするため、既存の GraphQL クライアントコードを一切変更せずにモックが実現できます。ただし Kotlin/JS から JavaScript ライブラリを呼ぶにはいくつかの工夫が必要でした。
この記事では、導入手順とハマったポイントをまとめます。
環境
| 項目 | バージョン |
|---|---|
| Kotlin | 2.1.x |
| Compose for Web | Multiplatform |
| MSW | 2.7.0 |
| Node.js | 20.11.0 |
| Ktor | 3.x |
| Webpack | Kotlin/JS デフォルト |
実装
1. Gradle に MSW を追加
// build.gradle.kts
kotlin {
js(IR) {
browser()
}
sourceSets {
val jsMain by getting {
dependencies {
implementation(npm("msw", "2.7.0"))
}
}
}
}
重要: MSW v2 は Node.js 20 以上が必要。Kotlin/JS のデフォルト Node バージョンが古い場合は明示的に指定する。
rootProject.plugins.withType<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin> {
rootProject.the<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension>().apply {
nodeVersion = "20.11.0"
}
}
2. Service Worker ファイルを配置
MSW 公式の mockServiceWorker.js を frontend/src/jsMain/resources/ に配置する。
npx msw init ./frontend/src/jsMain/resources/ --save
3. Webpack の resolve 設定
MSW v2 は package.json の exports フィールドを使ったサブパス解決を行う。Kotlin/JS の Webpack ではこれがデフォルトで解決できないため、設定を追加する。
// webpack.config.d/msw-resolve.js
;(function() {
const existingConditionNames = config.resolve.conditionNames || [];
config.resolve.conditionNames = [
'browser', 'import', 'require', 'default',
...existingConditionNames
];
})();
4. MSW セットアップ (MswSetup.kt)
// MswSetup.kt
private val msw: dynamic = js("require('msw')")
private val mswBrowser: dynamic = js("require('msw/browser')")
fun isDevelopmentHost(): Boolean {
val hostname = kotlinx.browser.window.location.hostname
return hostname == "localhost"
|| hostname == "127.0.0.1"
|| hostname == "::1"
}
fun isMswEnabled(): Boolean {
if (!isDevelopmentHost()) return false
val searchParams = js("new URLSearchParams(window.location.search)")
return (searchParams.get("msw") as? String) == "true"
}
fun setupMsw(): dynamic {
if (!isMswEnabled()) {
console.log("[MSW] disabled")
return null
}
MockData.publishToWindow()
val graphqlHandler = msw.http.post("/graphql", fun(info: dynamic): dynamic {
return info.request.json().then(fun(body: dynamic): dynamic {
val query = (body.query as? String) ?: ""
if (query.contains("GetTradeNewsByCategory")) {
val variables = body.variables
val category = (variables?.category as? String) ?: "ALL"
val allData = js("window.__mswMockData") as Array<dynamic>
val filtered = if (category == "ALL") allData
else allData.filter { it.category == category }
return@then msw.HttpResponse.json(js("({data: {tradeNews: filtered}})"))
}
val allData = js("window.__mswMockData")
return@then msw.HttpResponse.json(js("({data: {tradeNews: allData}})"))
})
})
val worker = mswBrowser.setupWorker(graphqlHandler)
return worker.start(js("({onUnhandledRequest: 'bypass'})"))
}
5. モックデータ定義 (MockData.kt)
// MockData.kt
@Serializable
data class NewsItem(
val id: String,
val title: String,
val titleJa: String,
val summary: String,
val summaryJa: String,
val category: String,
val publishedAt: String,
val translationStatus: String
)
object MockData {
private val json = Json { prettyPrint = false }
val newsItems = listOf(
NewsItem(
id = "mock-1",
title = "Lakers Trade Star Player in Blockbuster Deal",
titleJa = "レイカーズがスター選手を大型トレード",
summary = "The Los Angeles Lakers have ...",
summaryJa = "ロサンゼルス・レイカーズが...",
category = "Trade",
publishedAt = "2026-04-14T10:00:00Z",
translationStatus = "TRANSLATED"
),
// ... 他のモックデータ
)
fun publishToWindow() {
val jsonString = json.encodeToString(
ListSerializer(NewsItem.serializer()), newsItems
)
val parsed = kotlin.js.JSON.parse<dynamic>(jsonString)
kotlinx.browser.window.asDynamic().__mswMockData = parsed
}
}
6. エントリポイント (Main.kt)
fun main() {
val promise: dynamic = try {
setupMsw()
} catch (error: dynamic) {
console.error("[MSW] initialization failed:", error)
renderApp()
return
}
if (promise != null) {
promise.then {
window.asDynamic().__mswStarted = true
console.log("[MSW] started successfully")
renderApp()
}.catch { error: dynamic ->
console.error("[MSW] startup failed:", error)
renderApp()
}
} else {
renderApp()
}
}
7. UI インジケーター (App.kt)
val mswStarted = js("window.__mswStarted === true") as Boolean
if (mswStarted) {
Div(attrs = { classes("msw-banner") }) {
Text("MSW Mode — API responses are mocked")
}
Div(attrs = { classes("msw-indicator") }) {
Text("MSW")
}
}
ハマったポイント
1. js() は try/catch の中で使えない
// ❌ コンパイルエラー or 予期しない挙動
try {
val msw: dynamic = js("require('msw')")
} catch (e: Exception) { ... }
// ✅ js() はトップレベルまたはブロック外で呼ぶ
private val msw: dynamic = js("require('msw')")
Kotlin/JS の IR コンパイラは js() を特殊な lowering フェーズで処理するため、try/catch の lowering と競合する。js() による require() はファイルトップレベルまたは関数の先頭で行い、その後の処理を try/catch で保護する。
2. MSW ハンドラ内で suspend 関数が使えない
// ❌ ハンドラは suspend ではない
val handler = msw.http.post("/graphql", suspend fun(info: dynamic): dynamic {
val body = info.request.json().await() // これは動かない
})
// ✅ JavaScript の Promise チェーンを使う
val handler = msw.http.post("/graphql", fun(info: dynamic): dynamic {
return info.request.json().then(fun(body: dynamic): dynamic {
// ここで body を処理
return@then msw.HttpResponse.json(response)
})
})
MSW のハンドラは JavaScript の通常の関数として呼ばれるため、Kotlin の suspend 修飾子は無視される。request.json() は Promise を返すので、.then() でチェーンする。
3. Kotlin データクラスを JavaScript から読めない
// ❌ JavaScript 側から newsItems[0].title にアクセスできない
window.asDynamic().__mswMockData = newsItems
// ✅ JSON ラウンドトリップで JavaScript オブジェクトに変換
val jsonString = json.encodeToString(ListSerializer(NewsItem.serializer()), newsItems)
val parsed = kotlin.js.JSON.parse<dynamic>(jsonString)
window.asDynamic().__mswMockData = parsed
Kotlin データクラスのランタイム表現は JavaScript オブジェクトとは異なる。プロパティ名がマングルされていたり、プロトタイプチェーンが異なるため、JavaScript から直接アクセスできない。kotlinx.serialization → JSON.parse() の往復で純粋な JS オブジェクトに変換する。
4. Webpack の conditionNames が足りない
MSW v2 のインポート時にこんなエラーが出る:
Module not found: Can't resolve 'msw/browser'
MSW v2 は package.json の exports フィールドで条件付きサブパス解決を行う。Webpack のデフォルト conditionNames には browser や import が含まれていないため、明示的に追加する必要がある。
5. Node.js バージョンの罠
MSW v2 は Node.js 20 以上が必要。Kotlin/JS プロジェクトのデフォルト Node バージョンが古いと、npm install 自体は成功するがランタイムでエラーになる。build.gradle.kts で明示的にバージョンを指定すること。
サンプルリポジトリ
👉 toguri/example-msw-kotlin-compose
まとめ
Kotlin Compose for Web に MSW v2 を導入することで、既存の GraphQL クライアントコードを一切変更せずにネットワークモックを実現できた。
主要なポイントを振り返る:
-
dynamic型を活用して JavaScript ライブラリを呼び出す -
Promise チェーン (
.then()) でハンドラ内の非同期処理を行う - シリアライゼーション・ラウンドトリップで Kotlin ↔ JavaScript のデータ受け渡しを解決
-
Webpack の
conditionNamesを追加して MSW v2 のサブパス解決に対応 - 二重ガード(開発ホスト + URL パラメータ)で安全に有効化
Kotlin/JS エコシステムはまだ JavaScript エコシステムほど成熟していないが、dynamic 型と kotlinx.serialization を組み合わせれば、JavaScript の強力なツールチェーンを十分に活用できる。