0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Kotlin/JS】Compose for Web に MSW v2 を導入して GraphQL をモックする

0
Last updated at Posted at 2026-04-15

はじめに

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.jsfrontend/src/jsMain/resources/ に配置する。

npx msw init ./frontend/src/jsMain/resources/ --save

3. Webpack の resolve 設定

MSW v2 は package.jsonexports フィールドを使ったサブパス解決を行う。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.serializationJSON.parse() の往復で純粋な JS オブジェクトに変換する。

4. Webpack の conditionNames が足りない

MSW v2 のインポート時にこんなエラーが出る:

Module not found: Can't resolve 'msw/browser'

MSW v2 は package.jsonexports フィールドで条件付きサブパス解決を行う。Webpack のデフォルト conditionNames には browserimport が含まれていないため、明示的に追加する必要がある。

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 の強力なツールチェーンを十分に活用できる。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?