Kotlin MultipatformとFirestoreでNode.jsアプリ

# 概要/日記
Kotlin Multiplatform (for Javascript)と firebase-kotlin-SDK を使用し Node.js アプリを作る。
サンプルアプリはDB上の要求リストがあれば即座に応じてOSコマンドを実行しその結果を格納する。

これの続き:

https://qiita.com/shokkaa/items/15eb53db7914513189a4

# 前提
- Ubuntu 22.04 on Windwos 11 wsl2
- Firebaseプロジェクト・Firestoreが有効であること
- 認証無し。実験用

# Node.js用プロジェクト構成
```KtNodeSvr/build.gradle.kts

plugins {
    kotlin("multiplatform") version "2.0.0"
    kotlin("plugin.serialization") version "2.0.0"
}

repositories {
    mavenCentral()
}

kotlin {
    js {
        nodejs { }
        binaries.executable()
    }

    sourceSets {
        jsMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
            implementation("dev.gitlive:firebase-firestore:1.13.0") // https://mvnrepository.com/artifact/dev.gitlive/firebase-firestore
            implementation(npm("child_process", "1.0.2"))
            implementation(project(":shared"))
        }
    }
}
```
```settings.gradle.kts
rootProject.name = "KtMpApp"

include(":KtNodeSvr")
include(":KtHTMLFirestoreApp")
include(":shared")
```
```KtNodeSvr/src/jsMain/kotlin/KtNodeSvr.kt
import dev.gitlive.firebase.Firebase
import dev.gitlive.firebase.FirebaseOptions
import dev.gitlive.firebase.firestore.*
import dev.gitlive.firebase.initialize
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine


external val process: dynamic

val options = FirebaseOptions(
    apiKey = process.env.APPKEY as String,
    projectId = "xxxxxx",
    databaseUrl = "https://xxxxxxxxxxxxx.firebaseio.com",
    applicationId = "1:xxxxxxxxxxx:web:xxxxxxxxxxxxxxxxxxxx",
)

val app = Firebase.initialize(Unit, options)
val db = Firebase.firestore(app).apply {
    settings = firestoreSettings(settings) { cacheSettings = memoryCacheSettings { } }
}

external fun require(module: String): dynamic

suspend fun main() = runCatching {
    val args = (process.argv as Array<String>).drop(2)
    val (tg, pw) = args
    val refRqs = db.collection("fireshell").document(tg).collection("requests")

    refRqs.orderBy("time", Direction.ASCENDING).where { "isComplete" notEqualTo true }.limit(10).snapshots.collect {
        it.documents.forEach { ds ->
            val req = ds.data<Request>()
            runCatching {
                println("Run: ${req.cmd}")
                val res = spawn(req.cmd)
                ds.reference.set<Request>(req.copy(isComplete = true, result = res))
            }.onFailure {
                ds.reference.set<Request>(req.copy(isComplete = true, exception = it.message))
            }
        }
    }
}.onFailure { println(it.stackTraceToString()) }.getOrElse { }

suspend fun spawn(cmdLine: String) = suspendCoroutine { cont ->
    var r = 0
    val child_process = require("child_process")
    val (cmd, args) = cmdLine.split(" ").let { it.first() to it.drop(1).toTypedArray() }
    val ls = child_process.spawn(cmd, args)
    val stdout = mutableListOf<String>()
    val stderr = mutableListOf<String>()
    ls.stdout.on("data") { data -> stdout.add("$data") }
    ls.stderr.on("data", { data -> stderr.add("$data") })
    ls.on("close") { c -> if (r++ == 0) cont.resume(SpawnResult(c, stdout.joinToString(), stderr.joinToString())) }
    ls.on("error") { err -> if (r++ == 0) cont.resumeWithException(Exception("Error: spawn($cmdLine):${err}")) }
}
```
```shared/src/jsMain/kotlin/DataType.kt
import dev.gitlive.firebase.firestore.Timestamp
import dev.gitlive.firebase.firestore.Timestamp.Companion.now
import kotlinx.serialization.Serializable

@Serializable
data class SpawnResult(
    val exitCode: Int,
    val stdout: String,
    val stderr: String,
)

@Serializable
data class Request(
    val cmd: String,
    val time: Timestamp = now(),
    val isComplete: Boolean = false,
    val result: SpawnResult? = null,
    val exception: String? = null,
)
```

[ソースコード](https://github.com/kyoya-p/samples/tree/master/2024/FireShell)

# Firestore側の設定
```:Firestore → ルール
service cloud.firestore {
  match /databases/{db}/documents/fireshell/{document=**} {
    allow read, write: 
        // if request.resource.data.password == resource.data.password;
        if true;
	}
}
```
※このルールは無制限のアクセスを許す。テスト目的でのみ使用すること。
(APIKEYはクライアントアプリに含まれ秘匿されない)

```:Firestore→インデクス
コレクションID: requests
フィールド:
1. time=Accending(昇順)
2. isComplete=Accending(昇順)
クエリのスコープ: コレクション
```
複合インデクスは複数条件での列挙に必須

# ビルド/実行
```sh
sh gradlew kotlinNpmInstall
sh gradlew :KtNodeSvr:jsDevelopmentExecutableCompileSync
```
```sh
export APPKEY=<Firebase-App-Key>
node build/js/packages/KtMpApp-KtNodeSvr/kotlin/KtMpApp-KtNodeSvr.js default password
```