diff --git a/README.md b/README.md index 99c6fe9..c4b2b49 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ aether/ ├── aether-core/ # Core abstractions (Exchange, Pipeline, Dispatcher) ├── aether-db/ # ORM and database drivers ├── aether-web/ # Routing and controllers +├── aether-browser-client/ # Typed same-origin browser HTTP, navigation, and bootstrap APIs ├── aether-ui/ # UI rendering (SSR + CBOR) ├── aether-net/ # Network transport abstraction ├── aether-ksp/ # KSP-based migration generation diff --git a/aether-browser-client/README.md b/aether-browser-client/README.md new file mode 100644 index 0000000..3cad798 --- /dev/null +++ b/aether-browser-client/README.md @@ -0,0 +1,21 @@ +# Aether Browser Client + +`aether-browser-client` is Aether's small, browser-only HTTP and bootstrap layer for Kotlin/JS and +Kotlin/Wasm-JS applications. Browser interop stays inside Aether: applications use Kotlin APIs and +do not need handwritten JavaScript or direct DOM access. + +```kotlin +val client = BrowserHttpClient( + BrowserHttpClientConfig( + csrfProvider = BrowserCsrfProvider.sessionStorage("portfolio.csrf") + ) +) + +val result: RunResponse = client.post("/api/run", RunRequest(source)) +val state: InitialState = BrowserBootstrap.decode("portfolio-bootstrap") +BrowserHistory.replace("/playground#output") +``` + +Only same-origin relative request paths are accepted. Fetch always uses +`credentials = "same-origin"`; request and response limits, timeout, and redirect handling are +explicit configuration. diff --git a/aether-browser-client/build.gradle.kts b/aether-browser-client/build.gradle.kts new file mode 100644 index 0000000..9f44b40 --- /dev/null +++ b/aether-browser-client/build.gradle.kts @@ -0,0 +1,44 @@ +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + js(IR) { + browser { + testTask { + enabled = false + } + } + nodejs() + } + + wasmJs { + browser { + testTask { + enabled = false + } + } + nodejs() + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + api(libs.kotlinx.serialization.core) + api(libs.kotlinx.serialization.json) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + } +} diff --git a/aether-browser-client/gradle.lockfile b/aether-browser-client/gradle.lockfile new file mode 100644 index 0000000..fb21fdd --- /dev/null +++ b/aether-browser-client/gradle.lockfile @@ -0,0 +1,52 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation +org.jetbrains.kotlin:abi-tools-api:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:abi-tools:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-build-tools-api:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-dom-api-compat:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,jsCompileClasspath,jsMainResolvableDependenciesMetadata,jsNpmAggregated,jsRuntimeClasspath,jsTestCompileClasspath,jsTestNpmAggregated,jsTestResolvableDependenciesMetadata,jsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-klib-abi-reader:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.3.21=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.21=kotlinInternalAbiValidation +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJsMain,kotlinCompilerPluginClasspathJsTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathMetadataWebMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.3.21=kotlinCompilerPluginClasspathJsMain,kotlinCompilerPluginClasspathJsTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathMetadataWebMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.3.21=kotlinCompilerPluginClasspathJsMain,kotlinCompilerPluginClasspathJsTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathMetadataWebMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.3.21=kotlinCompilerPluginClasspathJsMain,kotlinCompilerPluginClasspathJsTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathMetadataWebMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest +org.jetbrains.kotlin:kotlin-scripting-jvm:2.3.21=kotlinCompilerPluginClasspathJsMain,kotlinCompilerPluginClasspathJsTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathMetadataWebMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest +org.jetbrains.kotlin:kotlin-serialization-compiler-plugin-embeddable:2.3.21=kotlinCompilerPluginClasspathJsMain,kotlinCompilerPluginClasspathJsTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathMetadataWebMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest +org.jetbrains.kotlin:kotlin-stdlib-common:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jsMainResolvableDependenciesMetadata,jsTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,metadataWebMainCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-js:2.3.21=jsCompileClasspath,jsNpmAggregated,jsRuntimeClasspath,jsTestCompileClasspath,jsTestNpmAggregated,jsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-wasm-js:2.3.21=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.3.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jsCompileClasspath,jsMainResolvableDependenciesMetadata,jsNpmAggregated,jsRuntimeClasspath,jsTestCompileClasspath,jsTestNpmAggregated,jsTestResolvableDependenciesMetadata,jsTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJsMain,kotlinCompilerPluginClasspathJsTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathMetadataWebMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,metadataWebMainCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-test-js:2.3.21=jsTestCompileClasspath,jsTestNpmAggregated,jsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test-wasm-js:2.3.21=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-test:2.3.21=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jsTestCompileClasspath,jsTestNpmAggregated,jsTestResolvableDependenciesMetadata,jsTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlin:kotlin-tooling-core:2.3.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-js:0.26.1=jsCompileClasspath,jsNpmAggregated,jsRuntimeClasspath,jsTestCompileClasspath,jsTestNpmAggregated,jsTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu-wasm-js:0.26.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:atomicfu:0.23.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jsMainResolvableDependenciesMetadata,jsTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath,metadataWebMainCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsTestResolvableDependenciesMetadata,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:atomicfu:0.26.1=jsCompileClasspath,jsNpmAggregated,jsRuntimeClasspath,jsTestCompileClasspath,jsTestNpmAggregated,jsTestRuntimeClasspath,wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-js:1.10.2=jsCompileClasspath,jsNpmAggregated,jsRuntimeClasspath,jsTestCompileClasspath,jsTestNpmAggregated,jsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-wasm-js:1.10.2=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jsCompileClasspath,jsMainResolvableDependenciesMetadata,jsNpmAggregated,jsRuntimeClasspath,jsTestCompileClasspath,jsTestNpmAggregated,jsTestResolvableDependenciesMetadata,jsTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,metadataWebMainCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-coroutines-test-js:1.10.2=jsTestCompileClasspath,jsTestNpmAggregated,jsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-wasm-js:1.10.2=wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=allTestSourceSetsCompileDependenciesMetadata,commonTestResolvableDependenciesMetadata,jsTestCompileClasspath,jsTestNpmAggregated,jsTestResolvableDependenciesMetadata,jsTestRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-serialization-core-js:1.9.0=jsCompileClasspath,jsNpmAggregated,jsRuntimeClasspath,jsTestCompileClasspath,jsTestNpmAggregated,jsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jsCompileClasspath,jsMainResolvableDependenciesMetadata,jsNpmAggregated,jsRuntimeClasspath,jsTestCompileClasspath,jsTestNpmAggregated,jsTestResolvableDependenciesMetadata,jsTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,metadataWebMainCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains.kotlinx:kotlinx-serialization-json-js:1.9.0=jsCompileClasspath,jsNpmAggregated,jsRuntimeClasspath,jsTestCompileClasspath,jsTestNpmAggregated,jsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json-wasm-js:1.9.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,jsCompileClasspath,jsMainResolvableDependenciesMetadata,jsNpmAggregated,jsRuntimeClasspath,jsTestCompileClasspath,jsTestNpmAggregated,jsTestResolvableDependenciesMetadata,jsTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath,metadataWebMainCompileClasspath,wasmJsCompileClasspath,wasmJsMainResolvableDependenciesMetadata,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestResolvableDependenciesMetadata,wasmJsTestRuntimeClasspath,webMainResolvableDependenciesMetadata,webTestResolvableDependenciesMetadata +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathJsMain,kotlinCompilerPluginClasspathJsTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathMetadataWebMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath +empty=commonMainImplementationDependenciesMetadata,commonTestImplementationDependenciesMetadata,jsMainImplementationDependenciesMetadata,jsTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,wasmJsMainImplementationDependenciesMetadata,wasmJsTestImplementationDependenciesMetadata,webMainImplementationDependenciesMetadata,webTestImplementationDependenciesMetadata diff --git a/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserBootstrap.kt b/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserBootstrap.kt new file mode 100644 index 0000000..dcff205 --- /dev/null +++ b/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserBootstrap.kt @@ -0,0 +1,117 @@ +package codes.yousef.aether.browser + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer + +enum class BrowserBootstrapEncoding { + JSON, + BASE64_JSON +} + +enum class BrowserBootstrapFailure { + MISSING, + EMPTY, + TOO_LARGE, + INVALID_ENCODING, + INVALID_JSON +} + +class BrowserBootstrapException( + val failure: BrowserBootstrapFailure, + message: String, + cause: Throwable? = null +) : Exception(message, cause) + +/** Reads bounded bootstrap data by element ID without exposing DOM APIs to application code. */ +object BrowserBootstrap { + inline fun decode( + elementId: String, + encoding: BrowserBootstrapEncoding = BrowserBootstrapEncoding.JSON, + maximumBytes: Int = DEFAULT_MAXIMUM_BROWSER_BOOTSTRAP_BYTES, + json: Json = Json { ignoreUnknownKeys = true } + ): T = decode(elementId, serializer(), encoding, maximumBytes, json) + + fun decode( + elementId: String, + deserializer: DeserializationStrategy, + encoding: BrowserBootstrapEncoding = BrowserBootstrapEncoding.JSON, + maximumBytes: Int = DEFAULT_MAXIMUM_BROWSER_BOOTSTRAP_BYTES, + json: Json = Json { ignoreUnknownKeys = true } + ): T { + requireValidBootstrapId(elementId) + require(maximumBytes in 1..MAXIMUM_BROWSER_BOOTSTRAP_BYTES) { + "Browser bootstrap limit must be between 1 byte and $MAXIMUM_BROWSER_BOOTSTRAP_BYTES bytes" + } + val stored = readPlatformBootstrapText(elementId) + ?: throw BrowserBootstrapException( + BrowserBootstrapFailure.MISSING, + "Browser bootstrap element was not found" + ) + return decodeStoredBootstrap(stored, deserializer, encoding, maximumBytes, json) + } +} + +internal fun decodeStoredBootstrap( + stored: String, + deserializer: DeserializationStrategy, + encoding: BrowserBootstrapEncoding, + maximumBytes: Int, + json: Json +): T { + require(maximumBytes in 1..MAXIMUM_BROWSER_BOOTSTRAP_BYTES) { + "Browser bootstrap limit must be between 1 byte and $MAXIMUM_BROWSER_BOOTSTRAP_BYTES bytes" + } + if (stored.isBlank()) { + throw BrowserBootstrapException(BrowserBootstrapFailure.EMPTY, "Browser bootstrap element was empty") + } + if (stored.encodeToByteArray().size > maximumBytes * 2) { + throw BrowserBootstrapException( + BrowserBootstrapFailure.TOO_LARGE, + "Browser bootstrap data exceeded its configured hard limit" + ) + } + val decoded = when (encoding) { + BrowserBootstrapEncoding.JSON -> stored + BrowserBootstrapEncoding.BASE64_JSON -> try { + decodePlatformBase64Utf8(stored.trim()) + } catch (error: Throwable) { + throw BrowserBootstrapException( + BrowserBootstrapFailure.INVALID_ENCODING, + "Browser bootstrap data was not valid base64-encoded UTF-8", + error + ) + } + } + if (decoded.encodeToByteArray().size > maximumBytes) { + throw BrowserBootstrapException( + BrowserBootstrapFailure.TOO_LARGE, + "Browser bootstrap data exceeded its configured hard limit" + ) + } + return try { + json.decodeFromString(deserializer, decoded) + } catch (error: SerializationException) { + throw BrowserBootstrapException( + BrowserBootstrapFailure.INVALID_JSON, + "Browser bootstrap data could not be decoded", + error + ) + } catch (error: IllegalArgumentException) { + throw BrowserBootstrapException( + BrowserBootstrapFailure.INVALID_JSON, + "Browser bootstrap data could not be decoded", + error + ) + } +} + +const val DEFAULT_MAXIMUM_BROWSER_BOOTSTRAP_BYTES: Int = 1 * 1_024 * 1_024 +const val MAXIMUM_BROWSER_BOOTSTRAP_BYTES: Int = 4 * 1_024 * 1_024 + +private fun requireValidBootstrapId(elementId: String) { + require(elementId.isNotBlank()) { "Browser bootstrap element ID must not be blank" } + require(elementId.length <= 256) { "Browser bootstrap element ID is too long" } + require(elementId.none(Char::isISOControl)) { "Browser bootstrap element ID contains control characters" } +} diff --git a/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserHttpClient.kt b/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserHttpClient.kt new file mode 100644 index 0000000..5744e06 --- /dev/null +++ b/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserHttpClient.kt @@ -0,0 +1,344 @@ +package codes.yousef.aether.browser + +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationException +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.serializer + +/** HTTP methods exposed by the focused browser client. */ +enum class BrowserHttpMethod { + GET, + POST +} + +/** Fetch redirect behavior. The default rejects redirects for API requests. */ +enum class BrowserRedirectPolicy(val fetchValue: String) { + FOLLOW("follow"), + ERROR("error"), + MANUAL("manual") +} + +/** Stable request metadata supplied to header and CSRF providers. */ +data class BrowserRequestMetadata( + val method: BrowserHttpMethod, + val path: String +) + +/** Supplies per-request headers without exposing browser primitives to the application. */ +fun interface BrowserHeadersProvider { + suspend fun headers(request: BrowserRequestMetadata): Map + + companion object { + val None: BrowserHeadersProvider = BrowserHeadersProvider { emptyMap() } + } +} + +/** A validated CSRF header emitted by [BrowserCsrfProvider]. */ +data class BrowserCsrfHeader( + val name: String = "X-CSRF-Token", + val value: String +) { + init { + requireValidHeader(name, value) + } +} + +/** Supplies CSRF material for unsafe same-origin requests. */ +fun interface BrowserCsrfProvider { + suspend fun header(request: BrowserRequestMetadata): BrowserCsrfHeader? + + companion object { + val None: BrowserCsrfProvider = BrowserCsrfProvider { null } + + fun fixed(value: String, headerName: String = "X-CSRF-Token"): BrowserCsrfProvider = + BrowserCsrfProvider { BrowserCsrfHeader(headerName, value) } + + fun sessionStorage( + storageKey: String, + headerName: String = "X-CSRF-Token" + ): BrowserCsrfProvider { + require(storageKey.isNotBlank()) { "CSRF session storage key must not be blank" } + require(storageKey.length <= MAXIMUM_STORAGE_KEY_CHARS) { "CSRF session storage key is too long" } + require(storageKey.none(Char::isISOControl)) { "CSRF session storage key contains control characters" } + return BrowserCsrfProvider { + browserSessionStorageValue(storageKey) + ?.takeIf(String::isNotBlank) + ?.let { BrowserCsrfHeader(headerName, it) } + } + } + } +} + +/** Hard limits and request policy for [BrowserHttpClient]. */ +class BrowserHttpClientConfig( + defaultHeaders: Map = emptyMap(), + val headersProvider: BrowserHeadersProvider = BrowserHeadersProvider.None, + val csrfProvider: BrowserCsrfProvider = BrowserCsrfProvider.None, + val redirectPolicy: BrowserRedirectPolicy = BrowserRedirectPolicy.ERROR, + val timeoutMillis: Int = DEFAULT_BROWSER_HTTP_TIMEOUT_MILLIS, + val maximumRequestBytes: Int = DEFAULT_MAXIMUM_BROWSER_REQUEST_BYTES, + val maximumResponseBytes: Int = DEFAULT_MAXIMUM_BROWSER_RESPONSE_BYTES +) { + val defaultHeaders: Map = defaultHeaders.toMap() + + init { + require(timeoutMillis in 1..MAXIMUM_BROWSER_HTTP_TIMEOUT_MILLIS) { + "Browser HTTP timeout must be between 1 ms and $MAXIMUM_BROWSER_HTTP_TIMEOUT_MILLIS ms" + } + require(maximumRequestBytes in 1..MAXIMUM_BROWSER_HTTP_BODY_BYTES) { + "Browser HTTP request limit must be between 1 byte and $MAXIMUM_BROWSER_HTTP_BODY_BYTES bytes" + } + require(maximumResponseBytes in 1..MAXIMUM_BROWSER_HTTP_BODY_BYTES) { + "Browser HTTP response limit must be between 1 byte and $MAXIMUM_BROWSER_HTTP_BODY_BYTES bytes" + } + requireValidHeaders(this.defaultHeaders) + } +} + +/** A bounded raw browser response. */ +data class BrowserHttpResponse( + val statusCode: Int, + val statusText: String, + val headers: Map, + val body: String +) { + val isSuccessful: Boolean get() = statusCode in 200..299 +} + +/** Normalized information decoded from a non-success response body. */ +data class BrowserHttpErrorEnvelope( + val code: String?, + val message: String?, + val requestId: String?, + val details: JsonElement?, + val raw: JsonObject? +) + +/** Browser transport failure categories which do not have an HTTP response. */ +enum class BrowserHttpFailure { + NETWORK, + TIMEOUT, + RESPONSE_TOO_LARGE, + UNSUPPORTED +} + +class BrowserHttpTransportException( + val failure: BrowserHttpFailure, + message: String, + cause: Throwable? = null +) : Exception(message, cause) + +class BrowserHttpRequestTooLargeException( + val actualBytes: Int, + val maximumBytes: Int +) : Exception("Browser HTTP request exceeded its configured hard limit") + +class BrowserHttpResponseTooLargeException( + val actualBytes: Int, + val maximumBytes: Int +) : Exception("Browser HTTP response exceeded its configured hard limit") + +class BrowserHttpResponseException( + val response: BrowserHttpResponse, + val error: BrowserHttpErrorEnvelope +) : Exception(error.message ?: "Browser HTTP request failed with status ${response.statusCode}") { + val statusCode: Int get() = response.statusCode +} + +class BrowserHttpDecodeException( + val statusCode: Int, + cause: Throwable +) : Exception("Browser HTTP response could not be decoded", cause) + +/** + * A same-origin, JSON-first browser client. + * + * Request paths must be root-relative, fetch credentials are always `same-origin`, and all + * responses are bounded before they are returned to callers. + */ +class BrowserHttpClient internal constructor( + private val config: BrowserHttpClientConfig, + private val json: Json, + private val transport: BrowserHttpTransport +) { + constructor( + config: BrowserHttpClientConfig = BrowserHttpClientConfig(), + json: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + ) : this(config, json, createPlatformBrowserHttpTransport()) + + suspend inline fun get( + path: String, + headers: Map = emptyMap() + ): Response = get(path, serializer(), headers) + + suspend fun get( + path: String, + responseDeserializer: DeserializationStrategy, + headers: Map = emptyMap() + ): Response = decode( + execute(BrowserHttpMethod.GET, path, body = null, headers = headers), + responseDeserializer + ) + + suspend inline fun post( + path: String, + body: Request, + headers: Map = emptyMap() + ): Response = post(path, body, serializer(), serializer(), headers) + + suspend fun post( + path: String, + body: Request, + requestSerializer: SerializationStrategy, + responseDeserializer: DeserializationStrategy, + headers: Map = emptyMap() + ): Response { + val encoded = json.encodeToString(requestSerializer, body) + return decode( + execute(BrowserHttpMethod.POST, path, body = encoded, headers = headers), + responseDeserializer + ) + } + + suspend fun execute( + method: BrowserHttpMethod, + path: String, + body: String? = null, + headers: Map = emptyMap() + ): BrowserHttpResponse { + requireSameOriginRequestPath(path) + if (method == BrowserHttpMethod.GET) { + require(body == null) { "GET requests must not include a body" } + } + + val requestBytes = body?.encodeToByteArray()?.size ?: 0 + if (requestBytes > config.maximumRequestBytes) { + throw BrowserHttpRequestTooLargeException(requestBytes, config.maximumRequestBytes) + } + + val metadata = BrowserRequestMetadata(method, path) + val mergedHeaders = linkedMapOf() + putHeader(mergedHeaders, "Accept", "application/json") + config.defaultHeaders.forEach { (name, value) -> putHeader(mergedHeaders, name, value) } + config.headersProvider.headers(metadata).forEach { (name, value) -> putHeader(mergedHeaders, name, value) } + headers.forEach { (name, value) -> putHeader(mergedHeaders, name, value) } + if (body != null && mergedHeaders.keys.none { it.equals("Content-Type", ignoreCase = true) }) { + putHeader(mergedHeaders, "Content-Type", "application/json") + } + if (method == BrowserHttpMethod.POST) { + config.csrfProvider.header(metadata)?.let { putHeader(mergedHeaders, it.name, it.value) } + } + requireValidHeaders(mergedHeaders) + + val response = try { + transport.execute( + BrowserTransportRequest( + method = method, + path = path, + headers = mergedHeaders.toMap(), + body = body, + redirectPolicy = config.redirectPolicy, + timeoutMillis = config.timeoutMillis, + maximumResponseBytes = config.maximumResponseBytes + ) + ) + } catch (cancelled: CancellationException) { + throw cancelled + } + + val responseBytes = response.body.encodeToByteArray().size + if (responseBytes > config.maximumResponseBytes) { + throw BrowserHttpResponseTooLargeException(responseBytes, config.maximumResponseBytes) + } + if (!response.isSuccessful) { + throw BrowserHttpResponseException(response, decodeErrorEnvelope(response)) + } + return response + } + + private fun decode( + response: BrowserHttpResponse, + deserializer: DeserializationStrategy + ): Response = try { + json.decodeFromString(deserializer, response.body) + } catch (error: SerializationException) { + throw BrowserHttpDecodeException(response.statusCode, error) + } catch (error: IllegalArgumentException) { + throw BrowserHttpDecodeException(response.statusCode, error) + } + + private fun decodeErrorEnvelope(response: BrowserHttpResponse): BrowserHttpErrorEnvelope { + val root = runCatching { json.parseToJsonElement(response.body) as? JsonObject }.getOrNull() + val errorElement = root?.get("error") + val errorObject = errorElement as? JsonObject + val primitiveError = (errorElement as? JsonPrimitive)?.contentOrNull + val message = errorObject.string("message") + ?: primitiveError + ?: root.string("message") + ?: response.statusText.takeIf(String::isNotBlank) + return BrowserHttpErrorEnvelope( + code = errorObject.string("code") ?: root.string("code"), + message = message, + requestId = root.string("requestId") + ?: root.string("request_id") + ?: response.headers.valueIgnoringCase("x-request-id"), + details = errorObject?.get("details") ?: root?.get("details"), + raw = root + ) + } +} + +const val DEFAULT_BROWSER_HTTP_TIMEOUT_MILLIS: Int = 30_000 +const val MAXIMUM_BROWSER_HTTP_TIMEOUT_MILLIS: Int = 5 * 60_000 +const val DEFAULT_MAXIMUM_BROWSER_REQUEST_BYTES: Int = 1 * 1_024 * 1_024 +const val DEFAULT_MAXIMUM_BROWSER_RESPONSE_BYTES: Int = 4 * 1_024 * 1_024 +const val MAXIMUM_BROWSER_HTTP_BODY_BYTES: Int = 16 * 1_024 * 1_024 + +private const val MAXIMUM_BROWSER_HTTP_HEADERS: Int = 128 +private const val MAXIMUM_BROWSER_HTTP_HEADER_VALUE_CHARS: Int = 8_192 +private const val MAXIMUM_STORAGE_KEY_CHARS: Int = 256 +private val BROWSER_HTTP_HEADER_NAME = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}") + +internal fun requireSameOriginRequestPath(path: String) { + require(path.startsWith('/') && !path.startsWith("//")) { + "Browser HTTP paths must be same-origin root-relative paths" + } + require('\\' !in path) { "Browser HTTP paths must not contain backslashes" } + require('#' !in path) { "Browser HTTP paths must not contain URL fragments" } + require(path.none(Char::isISOControl)) { "Browser HTTP paths must not contain control characters" } +} + +private fun requireValidHeaders(headers: Map) { + require(headers.size <= MAXIMUM_BROWSER_HTTP_HEADERS) { "Too many browser HTTP headers" } + headers.forEach { (name, value) -> requireValidHeader(name, value) } +} + +private fun requireValidHeader(name: String, value: String) { + require(BROWSER_HTTP_HEADER_NAME.matches(name)) { "Invalid browser HTTP header name" } + require(value.length <= MAXIMUM_BROWSER_HTTP_HEADER_VALUE_CHARS) { "Browser HTTP header value is too long" } + require(value.none { it == '\r' || it == '\n' || it == '\u0000' }) { + "Browser HTTP header value contains a control delimiter" + } +} + +private fun putHeader(headers: MutableMap, name: String, value: String) { + headers.keys.firstOrNull { it.equals(name, ignoreCase = true) }?.let(headers::remove) + headers[name] = value +} + +private fun JsonObject?.string(name: String): String? = + this?.get(name)?.let { element -> + (element as? JsonPrimitive)?.contentOrNull?.takeIf(String::isNotBlank) + } + +private fun Map.valueIgnoringCase(name: String): String? = + entries.firstOrNull { entry -> entry.key.equals(name, ignoreCase = true) }?.value diff --git a/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserNavigation.kt b/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserNavigation.kt new file mode 100644 index 0000000..24cf592 --- /dev/null +++ b/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserNavigation.kt @@ -0,0 +1,49 @@ +package codes.yousef.aether.browser + +import kotlinx.serialization.Serializable + +/** Immutable, typed projection of the current browser location. */ +@Serializable +data class BrowserLocationSnapshot( + val href: String, + val origin: String, + val pathname: String, + val search: String, + val hash: String +) + +object BrowserLocation { + fun current(): BrowserLocationSnapshot = readPlatformBrowserLocation() +} + +/** History operations which keep application code independent of DOM/browser globals. */ +object BrowserHistory { + fun push(path: String) { + requireSameOriginNavigationPath(path) + pushPlatformBrowserHistory(path) + } + + fun replace(path: String) { + requireSameOriginNavigationPath(path) + replacePlatformBrowserHistory(path) + } + + fun back() = go(-1) + + fun forward() = go(1) + + fun go(delta: Int) { + require(delta in -100..100) { "Browser history delta must be between -100 and 100" } + goPlatformBrowserHistory(delta) + } +} + +internal fun requireSameOriginNavigationPath(path: String) { + require(path.isNotBlank()) { "Browser navigation path must not be blank" } + require(path.startsWith('/') || path.startsWith('?') || path.startsWith('#')) { + "Browser navigation paths must be same-origin relative paths" + } + require(!path.startsWith("//")) { "Protocol-relative browser navigation is not allowed" } + require('\\' !in path) { "Browser navigation paths must not contain backslashes" } + require(path.none(Char::isISOControl)) { "Browser navigation paths must not contain control characters" } +} diff --git a/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserPlatform.kt b/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserPlatform.kt new file mode 100644 index 0000000..fe7d3e3 --- /dev/null +++ b/aether-browser-client/src/commonMain/kotlin/codes/yousef/aether/browser/BrowserPlatform.kt @@ -0,0 +1,31 @@ +package codes.yousef.aether.browser + +internal interface BrowserHttpTransport { + suspend fun execute(request: BrowserTransportRequest): BrowserHttpResponse +} + +internal data class BrowserTransportRequest( + val method: BrowserHttpMethod, + val path: String, + val headers: Map, + val body: String?, + val redirectPolicy: BrowserRedirectPolicy, + val timeoutMillis: Int, + val maximumResponseBytes: Int +) + +internal expect fun createPlatformBrowserHttpTransport(): BrowserHttpTransport + +internal expect fun browserSessionStorageValue(key: String): String? + +internal expect fun readPlatformBrowserLocation(): BrowserLocationSnapshot + +internal expect fun pushPlatformBrowserHistory(path: String) + +internal expect fun replacePlatformBrowserHistory(path: String) + +internal expect fun goPlatformBrowserHistory(delta: Int) + +internal expect fun readPlatformBootstrapText(elementId: String): String? + +internal expect fun decodePlatformBase64Utf8(value: String): String diff --git a/aether-browser-client/src/commonTest/kotlin/codes/yousef/aether/browser/BrowserBootstrapTest.kt b/aether-browser-client/src/commonTest/kotlin/codes/yousef/aether/browser/BrowserBootstrapTest.kt new file mode 100644 index 0000000..a4e76b9 --- /dev/null +++ b/aether-browser-client/src/commonTest/kotlin/codes/yousef/aether/browser/BrowserBootstrapTest.kt @@ -0,0 +1,59 @@ +package codes.yousef.aether.browser + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class BrowserBootstrapTest { + @Test + fun `stored JSON decodes through the same bounded path as DOM bootstrap data`() { + val decoded = decodeStoredBootstrap( + stored = """{"value":42}""", + deserializer = BootstrapState.serializer(), + encoding = BrowserBootstrapEncoding.JSON, + maximumBytes = 1_024, + json = Json + ) + + assertEquals(BootstrapState(42), decoded) + } + + @Test + fun `stored bootstrap hard limit is measured in UTF-8 bytes`() { + val failure = assertFailsWith { + decodeStoredBootstrap( + stored = """{"value":"éé"}""", + deserializer = BootstrapText.serializer(), + encoding = BrowserBootstrapEncoding.JSON, + maximumBytes = 15, + json = Json + ) + } + + assertEquals(BrowserBootstrapFailure.TOO_LARGE, failure.failure) + } + + @Test + fun `invalid stored JSON has a typed failure without payload disclosure`() { + val failure = assertFailsWith { + decodeStoredBootstrap( + stored = "sensitive-not-json", + deserializer = BootstrapState.serializer(), + encoding = BrowserBootstrapEncoding.JSON, + maximumBytes = 1_024, + json = Json + ) + } + + assertEquals(BrowserBootstrapFailure.INVALID_JSON, failure.failure) + check("sensitive-not-json" !in failure.message.orEmpty()) + } + + @Serializable + private data class BootstrapState(val value: Int) + + @Serializable + private data class BootstrapText(val value: String) +} diff --git a/aether-browser-client/src/commonTest/kotlin/codes/yousef/aether/browser/BrowserHttpClientTest.kt b/aether-browser-client/src/commonTest/kotlin/codes/yousef/aether/browser/BrowserHttpClientTest.kt new file mode 100644 index 0000000..e0a0cef --- /dev/null +++ b/aether-browser-client/src/commonTest/kotlin/codes/yousef/aether/browser/BrowserHttpClientTest.kt @@ -0,0 +1,262 @@ +package codes.yousef.aether.browser + +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BrowserHttpClientTest { + @Test + fun `get decodes response and merges request headers deterministically`() = runTest { + val transport = RecordingTransport { + BrowserHttpResponse(200, "OK", mapOf("content-type" to "application/json"), "{\"value\":42}") + } + val client = client( + transport = transport, + config = BrowserHttpClientConfig( + defaultHeaders = mapOf("X-Default" to "default", "accept" to "text/plain"), + headersProvider = BrowserHeadersProvider { mapOf("X-Provider" to it.path) } + ) + ) + + assertEquals(Reply(42), client.get("/api/value", headers = mapOf("X-Request" to "request"))) + + val request = transport.singleRequest() + assertEquals(BrowserHttpMethod.GET, request.method) + assertEquals("text/plain", request.headers.valueIgnoringCase("Accept")) + assertEquals("default", request.headers["X-Default"]) + assertEquals("/api/value", request.headers["X-Provider"]) + assertEquals("request", request.headers["X-Request"]) + assertNull(request.body) + } + + @Test + fun `post encodes JSON and authoritative CSRF header overrides caller value`() = runTest { + val transport = RecordingTransport { + BrowserHttpResponse(200, "OK", emptyMap(), "{\"value\":7}") + } + val client = client( + transport = transport, + config = BrowserHttpClientConfig( + csrfProvider = BrowserCsrfProvider.fixed("trusted-token") + ) + ) + + assertEquals( + Reply(7), + client.post("/api/value", Command("run"), mapOf("x-csrf-token" to "caller-token")) + ) + + val request = transport.singleRequest() + assertEquals("{\"action\":\"run\"}", request.body) + assertEquals("application/json", request.headers.valueIgnoringCase("Content-Type")) + assertEquals("trusted-token", request.headers.valueIgnoringCase("X-CSRF-Token")) + assertEquals(1, request.headers.keys.count { it.equals("X-CSRF-Token", ignoreCase = true) }) + } + + @Test + fun `only root-relative same-origin request paths are accepted`() = runTest { + val transport = RecordingTransport { okResponse() } + val client = client(transport) + + listOf( + "https://example.test/api", + "//example.test/api", + "api/value", + "/api/value#fragment", + "/api\\value" + ).forEach { path -> + assertFailsWith(path) { + client.execute(BrowserHttpMethod.GET, path) + } + } + assertTrue(transport.requests.isEmpty()) + } + + @Test + fun `request limits are measured as UTF-8 bytes before transport`() = runTest { + val transport = RecordingTransport { okResponse() } + val client = client( + transport, + BrowserHttpClientConfig(maximumRequestBytes = 3) + ) + + val failure = assertFailsWith { + client.execute(BrowserHttpMethod.POST, "/api/value", body = "éé") + } + + assertEquals(4, failure.actualBytes) + assertEquals(3, failure.maximumBytes) + assertTrue(transport.requests.isEmpty()) + } + + @Test + fun `response limit is enforced even when a transport violates its contract`() = runTest { + val transport = RecordingTransport { + BrowserHttpResponse(200, "OK", emptyMap(), "éé") + } + val client = client( + transport, + BrowserHttpClientConfig(maximumResponseBytes = 3) + ) + + val failure = assertFailsWith { + client.execute(BrowserHttpMethod.GET, "/api/value") + } + + assertEquals(4, failure.actualBytes) + assertEquals(3, failure.maximumBytes) + } + + @Test + fun `non-success nested error is normalized into typed exception`() = runTest { + val transport = RecordingTransport { + BrowserHttpResponse( + 422, + "Unprocessable Content", + mapOf("x-request-id" to "header-request"), + """ + { + "error": { + "code": "invalid_source", + "message": "Source is invalid", + "details": {"field": "source"} + }, + "requestId": "request-1" + } + """.trimIndent() + ) + } + val client = client(transport) + + val failure = assertFailsWith { + client.get("/api/value") + } + + assertEquals(422, failure.statusCode) + assertEquals("invalid_source", failure.error.code) + assertEquals("Source is invalid", failure.error.message) + assertEquals("request-1", failure.error.requestId) + val details = assertIs(failure.error.details) + assertEquals("source", details.getValue("field").jsonPrimitive.content) + } + + @Test + fun `request ID falls back to a case-insensitive response header`() = runTest { + val transport = RecordingTransport { + BrowserHttpResponse( + 500, + "Internal Server Error", + mapOf("X-Request-ID" to "request-from-header"), + "{}" + ) + } + + val failure = assertFailsWith { + client(transport).get("/api/value") + } + + assertEquals("request-from-header", failure.error.requestId) + } + + @Test + fun `simple string error envelope is retained`() = runTest { + val transport = RecordingTransport { + BrowserHttpResponse(400, "Bad Request", emptyMap(), "{\"error\":\"No code provided\"}") + } + val failure = assertFailsWith { + client(transport).get("/api/value") + } + + assertEquals("No code provided", failure.error.message) + assertNull(failure.error.code) + } + + @Test + fun `invalid success payload raises decode exception without exposing body in message`() = runTest { + val transport = RecordingTransport { + BrowserHttpResponse(200, "OK", emptyMap(), "not-json-sensitive-value") + } + + val failure = assertFailsWith { + client(transport).get("/api/value") + } + + assertEquals(200, failure.statusCode) + assertTrue("not-json-sensitive-value" !in failure.message.orEmpty()) + } + + @Test + fun `redirect timeout and response bounds reach the platform transport`() = runTest { + val transport = RecordingTransport { okResponse() } + val client = client( + transport, + BrowserHttpClientConfig( + redirectPolicy = BrowserRedirectPolicy.MANUAL, + timeoutMillis = 1_234, + maximumResponseBytes = 5_678 + ) + ) + + client.execute(BrowserHttpMethod.GET, "/api/value") + + val request = transport.singleRequest() + assertEquals(BrowserRedirectPolicy.MANUAL, request.redirectPolicy) + assertEquals(1_234, request.timeoutMillis) + assertEquals(5_678, request.maximumResponseBytes) + } + + @Test + fun `provider header delimiters are rejected before fetch`() = runTest { + val transport = RecordingTransport { okResponse() } + val client = client( + transport, + BrowserHttpClientConfig( + headersProvider = BrowserHeadersProvider { mapOf("X-Test" to "value\r\ninjected") } + ) + ) + + assertFailsWith { + client.execute(BrowserHttpMethod.GET, "/api/value") + } + assertTrue(transport.requests.isEmpty()) + } + + private fun client( + transport: BrowserHttpTransport, + config: BrowserHttpClientConfig = BrowserHttpClientConfig() + ): BrowserHttpClient = BrowserHttpClient(config, kotlinx.serialization.json.Json, transport) + + private fun okResponse(): BrowserHttpResponse = BrowserHttpResponse(200, "OK", emptyMap(), "{}") + + @Serializable + private data class Reply(val value: Int) + + @Serializable + private data class Command(val action: String) + + private class RecordingTransport( + private val response: suspend (BrowserTransportRequest) -> BrowserHttpResponse + ) : BrowserHttpTransport { + val requests = mutableListOf() + + override suspend fun execute(request: BrowserTransportRequest): BrowserHttpResponse { + requests += request + return response(request) + } + + fun singleRequest(): BrowserTransportRequest { + assertEquals(1, requests.size) + return requests.single() + } + } +} + +private fun Map.valueIgnoringCase(name: String): String? = + entries.singleOrNull { it.key.equals(name, ignoreCase = true) }?.value diff --git a/aether-browser-client/src/commonTest/kotlin/codes/yousef/aether/browser/BrowserNavigationTest.kt b/aether-browser-client/src/commonTest/kotlin/codes/yousef/aether/browser/BrowserNavigationTest.kt new file mode 100644 index 0000000..5ab64f1 --- /dev/null +++ b/aether-browser-client/src/commonTest/kotlin/codes/yousef/aether/browser/BrowserNavigationTest.kt @@ -0,0 +1,20 @@ +package codes.yousef.aether.browser + +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class BrowserNavigationTest { + @Test + fun `same-origin navigation accepts paths queries and fragments`() { + listOf("/photography", "/photography#gallery", "?filter=video", "#gallery").forEach { + requireSameOriginNavigationPath(it) + } + } + + @Test + fun `same-origin navigation rejects external and protocol-relative targets`() { + listOf("https://example.test", "//example.test/path", "example.test/path", "/bad\\path").forEach { + assertFailsWith { requireSameOriginNavigationPath(it) } + } + } +} diff --git a/aether-browser-client/src/jsMain/kotlin/codes/yousef/aether/browser/BrowserPlatform.js.kt b/aether-browser-client/src/jsMain/kotlin/codes/yousef/aether/browser/BrowserPlatform.js.kt new file mode 100644 index 0000000..0c19d5b --- /dev/null +++ b/aether-browser-client/src/jsMain/kotlin/codes/yousef/aether/browser/BrowserPlatform.js.kt @@ -0,0 +1,146 @@ +package codes.yousef.aether.browser + +import kotlinx.browser.document +import kotlinx.browser.window +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.await +import kotlinx.coroutines.withTimeout +import org.w3c.fetch.Headers +import org.w3c.fetch.RequestInit +import org.w3c.fetch.Response +import kotlin.js.Promise + +internal actual fun createPlatformBrowserHttpTransport(): BrowserHttpTransport = JsBrowserHttpTransport() + +private class JsBrowserHttpTransport : BrowserHttpTransport { + override suspend fun execute(request: BrowserTransportRequest): BrowserHttpResponse { + val abortController = js("new AbortController()") + val headers = Headers() + request.headers.forEach { (name, value) -> headers.set(name, value) } + val init = RequestInit( + method = request.method.name, + headers = headers, + body = request.body + ) + init.asDynamic().credentials = "same-origin" + init.asDynamic().mode = "same-origin" + init.asDynamic().redirect = request.redirectPolicy.fetchValue + init.asDynamic().signal = abortController.signal + + return try { + withTimeout(request.timeoutMillis.toLong()) { + val response = window.fetch(request.path, init).await() + val responseHeaders = linkedMapOf() + response.headers.asDynamic().forEach { value: String, name: String -> + responseHeaders[name] = value + } + BrowserHttpResponse( + statusCode = response.status.toInt(), + statusText = response.statusText, + headers = responseHeaders, + body = readBoundedResponseBody(response, request.maximumResponseBytes) + ) + } + } catch (timeout: TimeoutCancellationException) { + abortController.abort() + throw BrowserHttpTransportException( + BrowserHttpFailure.TIMEOUT, + "Browser HTTP request timed out", + timeout + ) + } catch (cancelled: CancellationException) { + abortController.abort() + throw cancelled + } catch (failure: BrowserHttpTransportException) { + abortController.abort() + throw failure + } catch (failure: Throwable) { + abortController.abort() + throw BrowserHttpTransportException( + BrowserHttpFailure.NETWORK, + "Browser HTTP request failed", + failure + ) + } + } +} + +private suspend fun readBoundedResponseBody(response: Response, maximumBytes: Int): String { + val declaredBytes = response.headers.get("content-length")?.toLongOrNull() + if (declaredBytes != null && declaredBytes > maximumBytes) { + throw BrowserHttpTransportException( + BrowserHttpFailure.RESPONSE_TOO_LARGE, + "Browser HTTP response exceeded its configured hard limit" + ) + } + + val stream = response.asDynamic().body + if (stream == null || stream.getReader == null) { + val buffer = response.arrayBuffer().await() + val size = (buffer.asDynamic().byteLength as Number).toInt() + if (size > maximumBytes) { + throw BrowserHttpTransportException( + BrowserHttpFailure.RESPONSE_TOO_LARGE, + "Browser HTTP response exceeded its configured hard limit" + ) + } + val decoder = js("new TextDecoder('utf-8')") + val bytes = js("new Uint8Array(buffer)") + return decoder.decode(bytes) as String + } + + val reader = stream.getReader() + val decoder = js("new TextDecoder('utf-8')") + val streamOptions = js("({ stream: true })") + val output = StringBuilder() + var totalBytes = 0 + while (true) { + val result = reader.read().unsafeCast>().await() + if (result.done as Boolean) break + val chunk = result.value + totalBytes += (chunk.byteLength as Number).toInt() + if (totalBytes > maximumBytes) { + reader.cancel("response_too_large") + throw BrowserHttpTransportException( + BrowserHttpFailure.RESPONSE_TOO_LARGE, + "Browser HTTP response exceeded its configured hard limit" + ) + } + output.append(decoder.decode(chunk, streamOptions) as String) + } + output.append(decoder.decode() as String) + return output.toString() +} + +internal actual fun browserSessionStorageValue(key: String): String? = + runCatching { window.sessionStorage.getItem(key) }.getOrNull() + +internal actual fun readPlatformBrowserLocation(): BrowserLocationSnapshot = BrowserLocationSnapshot( + href = window.location.href, + origin = window.location.origin, + pathname = window.location.pathname, + search = window.location.search, + hash = window.location.hash +) + +internal actual fun pushPlatformBrowserHistory(path: String) { + window.history.pushState(null, "", path) +} + +internal actual fun replacePlatformBrowserHistory(path: String) { + window.history.replaceState(null, "", path) +} + +internal actual fun goPlatformBrowserHistory(delta: Int) { + window.history.go(delta) +} + +internal actual fun readPlatformBootstrapText(elementId: String): String? = + document.getElementById(elementId)?.textContent + +internal actual fun decodePlatformBase64Utf8(value: String): String { + val binary = window.atob(value) + return ByteArray(binary.length) { index -> binary[index].code.toByte() } + .decodeToString(throwOnInvalidSequence = true) +} diff --git a/aether-browser-client/src/wasmJsMain/kotlin/codes/yousef/aether/browser/BrowserPlatform.wasmJs.kt b/aether-browser-client/src/wasmJsMain/kotlin/codes/yousef/aether/browser/BrowserPlatform.wasmJs.kt new file mode 100644 index 0000000..8db9849 --- /dev/null +++ b/aether-browser-client/src/wasmJsMain/kotlin/codes/yousef/aether/browser/BrowserPlatform.wasmJs.kt @@ -0,0 +1,232 @@ +@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class) + +package codes.yousef.aether.browser + +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal actual fun createPlatformBrowserHttpTransport(): BrowserHttpTransport = WasmJsBrowserHttpTransport() + +private class WasmJsBrowserHttpTransport : BrowserHttpTransport { + private val json = Json + + override suspend fun execute(request: BrowserTransportRequest): BrowserHttpResponse = + suspendCancellableCoroutine { continuation -> + var requestId = -1 + val callback: (Int, String?, String?, String?, String?) -> Unit = + callback@{ status, statusText, headersJson, body, failureCode -> + if (failureCode != null) { + val failure = when (failureCode) { + "timeout" -> BrowserHttpFailure.TIMEOUT + "response_too_large" -> BrowserHttpFailure.RESPONSE_TOO_LARGE + "unsupported" -> BrowserHttpFailure.UNSUPPORTED + else -> BrowserHttpFailure.NETWORK + } + if (!continuation.isActive) return@callback + continuation.resumeWithException( + BrowserHttpTransportException(failure, "Browser HTTP request failed: $failureCode") + ) + return@callback + } + + val responseHeaders = runCatching { + json.decodeFromString>(headersJson ?: "{}") + }.getOrDefault(emptyMap()) + if (!continuation.isActive) return@callback + continuation.resume( + BrowserHttpResponse( + statusCode = status, + statusText = statusText.orEmpty(), + headers = responseHeaders, + body = body.orEmpty() + ) + ) + } + + requestId = startBrowserFetch( + path = request.path, + method = request.method.name, + headersJson = json.encodeToString(request.headers), + body = request.body, + redirect = request.redirectPolicy.fetchValue, + timeoutMillis = request.timeoutMillis, + maximumResponseBytes = request.maximumResponseBytes, + callback = callback + ) + continuation.invokeOnCancellation { + if (requestId >= 0) cancelBrowserFetch(requestId) + } + } +} + +@JsFun( + """ +(path, method, headersJson, body, redirect, timeoutMillis, maximumResponseBytes, callback) => { + const registry = globalThis.__aetherBrowserClientRequests || + (globalThis.__aetherBrowserClientRequests = {nextId: 1, requests: new Map()}); + const id = registry.nextId++; + if (typeof globalThis.fetch !== 'function' || + typeof globalThis.AbortController !== 'function' || + typeof globalThis.TextDecoder !== 'function') { + callback(0, null, null, null, 'unsupported'); + return id; + } + + const controller = new AbortController(); + const entry = {controller, timer: null, timedOut: false}; + registry.requests.set(id, entry); + const finish = (status, statusText, headers, responseBody, failure) => { + const current = registry.requests.get(id); + if (!current) return; + if (current.timer !== null) clearTimeout(current.timer); + registry.requests.delete(id); + callback(status, statusText, headers, responseBody, failure); + }; + entry.timer = setTimeout(() => { + entry.timedOut = true; + controller.abort(); + }, timeoutMillis); + + const headers = JSON.parse(headersJson); + const options = { + method, + headers, + body: body === null ? undefined : body, + credentials: 'same-origin', + mode: 'same-origin', + redirect, + signal: controller.signal + }; + + fetch(path, options).then(async response => { + const responseHeaders = {}; + response.headers.forEach((value, name) => { responseHeaders[name] = value; }); + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > maximumResponseBytes) { + controller.abort(); + finish(0, null, null, null, 'response_too_large'); + return; + } + + const decoder = new TextDecoder('utf-8'); + let text = ''; + let total = 0; + if (response.body && response.body.getReader) { + const reader = response.body.getReader(); + while (true) { + const result = await reader.read(); + if (result.done) break; + total += result.value.byteLength; + if (total > maximumResponseBytes) { + await reader.cancel('response_too_large').catch(() => undefined); + controller.abort(); + finish(0, null, null, null, 'response_too_large'); + return; + } + text += decoder.decode(result.value, {stream: true}); + } + text += decoder.decode(); + } else { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maximumResponseBytes) { + finish(0, null, null, null, 'response_too_large'); + return; + } + text = decoder.decode(bytes); + } + finish(response.status, response.statusText, JSON.stringify(responseHeaders), text, null); + }).catch(() => { + finish(0, null, null, null, entry.timedOut ? 'timeout' : 'network'); + }); + return id; +} +""" +) +private external fun startBrowserFetch( + path: String, + method: String, + headersJson: String, + body: String?, + redirect: String, + timeoutMillis: Int, + maximumResponseBytes: Int, + callback: (Int, String?, String?, String?, String?) -> Unit +): Int + +@JsFun( + """ +id => { + const registry = globalThis.__aetherBrowserClientRequests; + const entry = registry && registry.requests.get(id); + if (!entry) return; + if (entry.timer !== null) clearTimeout(entry.timer); + registry.requests.delete(id); + entry.controller.abort(); +} +""" +) +private external fun cancelBrowserFetch(requestId: Int) + +@JsFun( + """ +key => { + try { + return globalThis.sessionStorage ? globalThis.sessionStorage.getItem(key) : null; + } catch (_) { + return null; + } +} +""" +) +private external fun readSessionStorageValue(key: String): String? + +internal actual fun browserSessionStorageValue(key: String): String? = readSessionStorageValue(key) + +@JsFun( + """ +() => JSON.stringify({ + href: globalThis.location.href, + origin: globalThis.location.origin, + pathname: globalThis.location.pathname, + search: globalThis.location.search, + hash: globalThis.location.hash +}) +""" +) +private external fun browserLocationJson(): String + +internal actual fun readPlatformBrowserLocation(): BrowserLocationSnapshot = + Json.decodeFromString(browserLocationJson()) + +@JsFun("path => globalThis.history.pushState(null, '', path)") +private external fun pushHistory(path: String) + +internal actual fun pushPlatformBrowserHistory(path: String) = pushHistory(path) + +@JsFun("path => globalThis.history.replaceState(null, '', path)") +private external fun replaceHistory(path: String) + +internal actual fun replacePlatformBrowserHistory(path: String) = replaceHistory(path) + +@JsFun("delta => globalThis.history.go(delta)") +private external fun goHistory(delta: Int) + +internal actual fun goPlatformBrowserHistory(delta: Int) = goHistory(delta) + +@JsFun("id => globalThis.document ? globalThis.document.getElementById(id)?.textContent ?? null : null") +private external fun bootstrapText(elementId: String): String? + +internal actual fun readPlatformBootstrapText(elementId: String): String? = bootstrapText(elementId) + +@JsFun("value => globalThis.atob(value)") +private external fun decodeBase64(value: String): String + +internal actual fun decodePlatformBase64Utf8(value: String): String { + val binary = decodeBase64(value) + return ByteArray(binary.length) { index -> binary[index].code.toByte() } + .decodeToString(throwOnInvalidSequence = true) +} diff --git a/build.gradle.kts b/build.gradle.kts index a49c37c..1a38c45 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -115,7 +115,8 @@ fun expectedKmpCompileTasks(module: String, targets: List): List val identityAuthorityTargets = listOf("Jvm", "WasmJs", "WasmWasi") val expectedSourceTaskPaths = - expectedKmpCompileTasks("aether-auth", identityAuthorityTargets) + + expectedKmpCompileTasks("aether-browser-client", listOf("Js", "WasmJs")) + + expectedKmpCompileTasks("aether-auth", identityAuthorityTargets) + expectedKmpCompileTasks("aether-auth-postgresql", identityAuthorityTargets) + expectedKmpCompileTasks("aether-auth-firestore", identityAuthorityTargets) + expectedKmpCompileTasks("aether-auth-oidc", identityAuthorityTargets) + diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 2aca1cf..0e63f8d 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -4334,6 +4334,14 @@ + + + + + + + + @@ -4678,6 +4686,14 @@ + + + + + + + + @@ -4705,6 +4721,14 @@ + + + + + + + + @@ -4829,6 +4853,14 @@ + + + + + + + + @@ -4905,6 +4937,14 @@ + + + + + + + + @@ -4977,6 +5017,14 @@ + + + + + + + + @@ -5135,6 +5183,14 @@ + + + + + + + + @@ -5180,6 +5236,14 @@ + + + + + + + + @@ -5477,6 +5541,11 @@ + + + + + diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index a35a541..1785692 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -2,17 +2,545 @@ # yarn lockfile v1 -"@js-joda/core@3.2.0": +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +brace-expansion@^2.0.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.2.tgz#0bba2271feb7d458b0d31ad13625aaa4754431e2" + integrity sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA== + dependencies: + balanced-match "^1.0.0" + +browser-stdout@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +camelcase@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chokidar@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@^4.3.5: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +diff@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" + integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +escalade@^3.1.1: version "3.2.0" - resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" - integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" format-util@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271" integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg== -typescript@5.5.4: - version "5.5.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" - integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +glob@^10.4.5: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +js-yaml@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.0.tgz#d1900572a7f7cf0b5f540c83673e60bad3436592" + integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q== + dependencies: + argparse "^2.0.1" + +kotlin-web-helpers@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/kotlin-web-helpers/-/kotlin-web-helpers-3.0.0.tgz#3ed6b48f694f74bb60a737a9d7e2c0e3b29abdb9" + integrity sha512-kdQO4AJQkUPvpLh9aglkXDRyN+CfXO7pKq+GESEnxooBFkQpytLrqZis3ABvmFN1cGw/ZQ/K38u5sRGW+NfBnw== + dependencies: + format-util "^1.0.5" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +minimatch@^9.0.4, minimatch@^9.0.5: + version "9.0.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== + dependencies: + brace-expansion "^2.0.2" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + +mocha@11.7.5: + version "11.7.5" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.5.tgz#58f5bbfa5e0211ce7e5ee6128107cefc2515a627" + integrity sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig== + dependencies: + browser-stdout "^1.3.1" + chokidar "^4.0.1" + debug "^4.3.5" + diff "^7.0.0" + escape-string-regexp "^4.0.0" + find-up "^5.0.0" + glob "^10.4.5" + he "^1.2.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + log-symbols "^4.1.0" + minimatch "^9.0.5" + ms "^2.1.3" + picocolors "^1.1.1" + serialize-javascript "^6.0.2" + strip-json-comments "^3.1.1" + supports-color "^8.1.1" + workerpool "^9.2.0" + yargs "^17.7.2" + yargs-parser "^21.1.1" + yargs-unparser "^2.0.0" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +safe-buffer@^5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +source-map-support@0.5.21: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + dependencies: + ansi-regex "^6.2.2" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +workerpool@^9.2.0: + version "9.3.4" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.3.4.tgz#f6c92395b2141afd78e2a889e80cb338fe9fca41" + integrity sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs-unparser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@^17.7.2: + version "17.7.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.3.tgz#779dffe6bcafec596a7172e983289a588647faaa" + integrity sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/settings.gradle.kts b/settings.gradle.kts index 91114d0..bb7c0b1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ include( ":aether-tasks", ":aether-channels", ":aether-web", + ":aether-browser-client", ":aether-ui", ":aether-net", ":aether-plugin",