feat: add runtime configuration for Caddy-based SPA containerization

Introduced `config.json` runtime configuration fetch mechanism to support the "Build Once, Deploy Everywhere" pattern. Replaced NGINX with Caddy for SPA deployment, enabling SPA routing, security headers, and static asset management. Updated Gradle and Kotlin/JS build configurations to align with the new runtime environment. Enhanced Dockerfile and health checks for optimized CI/CD workflows and improved SPA delivery.
This commit is contained in:
2026-02-02 16:19:20 +01:00
parent 86d8d780f5
commit 11c597f147
17 changed files with 327 additions and 193 deletions
+8 -4
View File
@@ -14,11 +14,15 @@ kotlin {
jvm()
js {
browser {
testTask {
enabled = false
}
// browser {} block removed to avoid NodeJsRootPlugin conflicts in multi-module builds
// We only need explicit browser configuration in the shell (application) module.
// Tests are disabled via root build.gradle.kts configuration anyway.
nodejs {
testTask {
enabled = false
}
}
binaries.library()
}
sourceSets {
+6 -27
View File
@@ -6,48 +6,27 @@ plugins {
}
kotlin {
// Toolchain is now handled centrally in the root build.gradle.kts
jvm()
js(IR) {
browser()
// nodejs()
}
// Wasm vorerst deaktiviert
/*
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
js(IR) {
binaries.library()
// Explicitly select browser environment to satisfy Kotlin/JS compiler warning
browser {
testTask { enabled = false }
}
}
*/
sourceSets {
commonMain.dependencies {
// Compose dependencies
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
// Coroutines
implementation(libs.kotlinx.coroutines.core)
// Serialization
implementation(libs.kotlinx.serialization.json)
// DateTime
implementation(libs.kotlinx.datetime)
}
jsMain.dependencies {
// JS-specific UI dependencies if needed
}
jvmMain.dependencies {
// JVM-specific UI dependencies if needed
}
}
}
+2 -12
View File
@@ -9,24 +9,14 @@ plugins {
}
kotlin {
// Toolchain is now handled centrally in the root build.gradle.kts
// Wasm is now a first-class citizen in our stack, so we enable it by default
// val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvm()
js {
binaries.library()
browser {
testTask { enabled = false }
testTask { enabled = false }
}
}
// Wasm vorerst deaktiviert
/*
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() }
*/
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.serialization.json)
+4 -23
View File
@@ -9,28 +9,18 @@ plugins {
}
kotlin {
// Toolchain is now handled centrally in the root build.gradle.kts
jvm()
js {
binaries.library()
browser {
testTask { enabled = false }
testTask { enabled = false }
}
binaries.executable()
}
// Wasm vorerst deaktiviert, um Stabilität mit JS zu gewährleisten
/*
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
}
*/
sourceSets {
commonMain.dependencies {
implementation(libs.koin.core)
implementation(libs.bundles.kmp.common) // Coroutines, Serialization, DateTime
implementation(libs.bundles.kmp.common)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines)
}
@@ -41,18 +31,9 @@ kotlin {
jsMain.dependencies {
implementation(libs.sqldelight.driver.web)
// NPM deps used by `sqlite.worker.js` (OPFS-backed SQLite WASM worker)
implementation(npm("@sqlite.org/sqlite-wasm", "3.51.1-build2"))
}
/*
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.sqldelight.driver.web)
}
*/
commonTest.dependencies {
implementation(libs.kotlin.test)
}
@@ -63,7 +44,7 @@ sqldelight {
databases {
create("AppDatabase") {
packageName.set("at.mocode.frontend.core.localdb")
generateAsync.set(true) // WICHTIG: Async-First für JS Support
generateAsync.set(true)
}
}
}
+2 -16
View File
@@ -10,28 +10,14 @@ group = "at.mocode.clients.shared"
version = "1.0.0"
kotlin {
// Toolchain is now handled centrally in the root build.gradle.kts
jvm()
js {
binaries.library()
browser {
testTask {
// Browser testing is disabled to avoid environment issues (e.g. missing ChromeHeadless).
// Tests are still run on JVM.
enabled = false
}
testTask { enabled = false }
}
}
// Wasm vorerst deaktiviert
/*
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
}
*/
sourceSets {
commonMain.dependencies {
// Depend on core domain for User/Role types used by navigation API
+2 -25
View File
@@ -9,39 +9,23 @@ plugins {
}
kotlin {
// Toolchain is now handled centrally in the root build.gradle.kts
jvm()
js {
binaries.library()
browser {
testTask { enabled = false }
testTask { enabled = false }
}
}
// Wasm vorerst deaktiviert
/*
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() }
*/
sourceSets {
commonMain.dependencies {
// Ktor Client core + JSON and Auth + Logging + Timeouts + Retry
api(libs.ktor.client.core)
implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.logging)
// ktor-client-resources optional; disabled until version is added to catalog
// Kotlinx core bundles
implementation(libs.kotlinx.coroutines.core)
// DI (Koin)
api(libs.koin.core)
// Project modules via typesafe accessors
// (none here; kept for consistency)
}
jvmMain.dependencies {
@@ -51,13 +35,6 @@ kotlin {
jsMain.dependencies {
implementation(libs.ktor.client.js)
}
/*
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.ktor.client.js)
}
*/
}
}
+7 -2
View File
@@ -4,15 +4,20 @@ plugins {
}
kotlin {
// Targets are configured centrally in the shells/feature modules; here we just provide common code.
jvm()
js(IR) {
browser()
binaries.library()
browser {
testTask { enabled = false }
}
}
sourceSets {
commonMain.dependencies {
// Correct dependency: Syncable interface is in the shared core domain
implementation(projects.core.coreDomain)
// Also include frontend domain if needed (e.g., for frontend-specific models)
implementation(projects.frontend.core.domain)
// Networking
implementation(libs.ktor.client.core)
@@ -2,8 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
/**
* Dieses Modul kapselt die gesamte UI und Logik für das Ping-Feature.
* Es kennt seine eigenen technischen Abhängigkeiten (Ktor, Coroutines)
* und den UI-Baukasten (common-ui), aber es kennt keine anderen Features.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
@@ -16,45 +14,23 @@ group = "at.mocode.clients"
version = "1.0.0"
kotlin {
// Toolchain is now handled centrally in the root build.gradle.kts
jvm()
js {
binaries.library()
browser {
testTask {
enabled = false
}
testTask { enabled = false }
}
}
// Wasm vorerst deaktiviert
/*
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
}
*/
sourceSets {
commonMain.dependencies {
// Contract from backend
implementation(projects.contracts.pingApi)
// UI Kit (Design System)
implementation(projects.frontend.core.designSystem)
// Generic Delta-Sync core
implementation(projects.frontend.core.sync)
// Local DB (SQLDelight)
implementation(projects.frontend.core.localDb)
implementation(libs.sqldelight.coroutines) // Explicitly add coroutines extension for async driver support
implementation(libs.sqldelight.coroutines)
implementation(projects.frontend.core.domain)
// Shared sync contract base (Syncable)
implementation(projects.core.coreDomain)
// Compose dependencies
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
@@ -62,12 +38,10 @@ kotlin {
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
// Bundles (Cleaned up dependencies)
implementation(libs.bundles.kmp.common) // Coroutines, Serialization, DateTime
implementation(libs.bundles.ktor.client.common) // Ktor Client (Core, Auth, JSON, Logging)
implementation(libs.bundles.compose.common) // ViewModel & Lifecycle
implementation(libs.bundles.kmp.common)
implementation(libs.bundles.ktor.client.common)
implementation(libs.bundles.compose.common)
// DI (Koin) for resolving apiClient from container
implementation(libs.koin.core)
}
@@ -78,7 +52,7 @@ kotlin {
}
jvmTest.dependencies {
implementation(libs.mockk) // MockK only for JVM tests
implementation(libs.mockk)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
}
@@ -90,22 +64,9 @@ kotlin {
jsMain.dependencies {
implementation(libs.ktor.client.js)
}
/*
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
// Compose für shared UI components für WASM
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
*/
}
}
// KMP Compile-Optionen
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_25)
@@ -132,8 +132,8 @@ class TestPingApiClient : PingApi {
return handleRequest(securePingResponse)
}
override suspend fun syncPings(lastSyncTimestamp: Long): List<PingEvent> {
syncPingsCalledWith = lastSyncTimestamp
override suspend fun syncPings(since: Long): List<PingEvent> {
syncPingsCalledWith = since
callCount++
if (simulateDelay) {
@@ -0,0 +1,26 @@
import kotlinx.browser.window
import kotlinx.coroutines.await
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class AppConfig(
val apiBaseUrl: String
)
suspend fun loadAppConfig(): AppConfig {
return try {
// Fetch config.json generated by Caddy templates
val response = window.fetch("/config.json").await()
if (!response.ok) {
console.warn("[Config] Failed to load config.json, falling back to defaults")
return AppConfig(apiBaseUrl = window.location.origin)
}
val text = response.text().await()
Json.decodeFromString(AppConfig.serializer(), text)
} catch (e: dynamic) {
console.error("[Config] Error loading config:", e)
// Fallback for local development if file is missing
AppConfig(apiBaseUrl = "http://localhost:8081")
}
}
@@ -21,20 +21,30 @@ import org.w3c.dom.HTMLElement
fun main() {
console.log("[WebApp] main() entered")
// 1. Initialize DI (Koin) with static modules
try {
startKoin { modules(networkModule, localDbModule, syncModule, pingFeatureModule, authModule, navigationModule) }
console.log("[WebApp] Koin initialized with static modules")
} catch (e: dynamic) {
console.warn("[WebApp] Koin initialization warning:", e)
}
// 2. Async Initialization Chain
// We must ensure DB is ready and registered in Koin BEFORE we mount the UI.
val provider = GlobalContext.get().get<DatabaseProvider>()
MainScope().launch {
try {
// 1. Load Runtime Configuration (Async)
console.log("[WebApp] Loading configuration...")
val config = loadAppConfig()
console.log("[WebApp] Configuration loaded: apiBaseUrl=${config.apiBaseUrl}")
// 2. Initialize DI (Koin)
// We register the config immediately so other modules can use it
startKoin {
modules(
module { single { config } }, // Make AppConfig available for injection
networkModule,
localDbModule,
syncModule,
pingFeatureModule,
authModule,
navigationModule
)
}
console.log("[WebApp] Koin initialized")
// 3. Initialize Database (Async)
val provider = GlobalContext.get().get<DatabaseProvider>()
console.log("[WebApp] Initializing Database...")
val db = provider.createDatabase()
@@ -46,12 +56,12 @@ fun main() {
)
console.log("[WebApp] Local DB created and registered in Koin")
// 3. Start App only after DB is ready
// 4. Start UI
startAppWhenDomReady()
} catch (e: dynamic) {
console.error("[WebApp] CRITICAL: Database initialization failed:", e)
renderFatalError("Database initialization failed: ${e?.message ?: e}")
console.error("[WebApp] CRITICAL: Initialization failed:", e)
renderFatalError("Initialization failed: ${e?.message ?: e}")
}
}
}