fix: ensure _webbricks_=1 is sent for all backend resource paths including /public

- isWebBricksBackendResource() now matches /public and / as WebBricks backend resources
- BricksHttp.kt switched from manual URL query string to Ktor parameter() API
- Added test/generic-client/build.sh for one-click build/run/package
This commit is contained in:
yumoqing 2026-05-20 08:18:58 +08:00
parent 43f416a6f0
commit d4f7e39834
3 changed files with 70 additions and 38 deletions

View File

@ -4,15 +4,16 @@ import com.bricks.mp.dev.DevHttpInterceptor
import com.bricks.mp.dev.DevLogSource import com.bricks.mp.dev.DevLogSource
import com.bricks.mp.dev.DevLogLevel import com.bricks.mp.dev.DevLogLevel
import com.bricks.mp.dev.DevLogStore import com.bricks.mp.dev.DevLogStore
import kotlinx.datetime.Clock
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.cookies.* import io.ktor.client.plugins.cookies.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.* import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.http.encodeURLParameter import kotlinx.datetime.Clock
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import kotlin.time.DurationUnit.MILLISECONDS
import kotlin.time.toDuration
/** /**
* HTTP error surfaced by BricksHttp before the response body is parsed. * HTTP error surfaced by BricksHttp before the response body is parsed.
@ -37,6 +38,9 @@ class BricksHttpException(
* Bricks HTTP 客户端 - 对应 JS HttpJson/HttpText * Bricks HTTP 客户端 - 对应 JS HttpJson/HttpText
* 支持 session cookie 管理和 Bearer token 认证并将 .ui/.dspy 请求自动追加 * 支持 session cookie 管理和 Bearer token 认证并将 .ui/.dspy 请求自动追加
* WebBricks 参数_webbricks__width_height_is_mobile_lang * WebBricks 参数_webbricks__width_height_is_mobile_lang
*
* 所有请求统一使用 Ktor HttpRequestBuilder.parameter() 添加查询参数
* 而非手动拼接 URL 字符串确保编码和传递可靠
*/ */
class BricksHttp(private val context: BricksContext? = null) { class BricksHttp(private val context: BricksContext? = null) {
@ -106,12 +110,12 @@ class BricksHttp(private val context: BricksContext? = null) {
authToken: String = "" authToken: String = ""
): JsonObject { ): JsonObject {
val requestParams = params.withBackendContextIfNeeded(url) val requestParams = params.withBackendContextIfNeeded(url)
val requestUrl = url.withQueryParameters(requestParams)
val requestBodyStr = Json { encodeDefaults = false }.encodeToString(JsonObject.serializer(), body) val requestBodyStr = Json { encodeDefaults = false }.encodeToString(JsonObject.serializer(), body)
val devEntry = DevHttpInterceptor.recordRequest(requestUrl, "POST", requestBodyStr) val devEntry = DevHttpInterceptor.recordRequest(url, "POST", requestBodyStr)
val startTime = Clock.System.now() val startTime = Clock.System.now()
return try { return try {
val response = client.post(requestUrl) { val response = client.post(url) {
requestParams.forEach { (key, value) -> parameter(key, value) }
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(body) setBody(body)
if (authToken.isNotEmpty()) { if (authToken.isNotEmpty()) {
@ -121,7 +125,7 @@ class BricksHttp(private val context: BricksContext? = null) {
val text = response.bodyAsText() val text = response.bodyAsText()
val duration = Clock.System.now() - startTime val duration = Clock.System.now() - startTime
DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds) DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds)
response.throwIfHttpError(text, requestUrl) response.throwIfHttpError(text, url)
parseJsonObjectOrError(text) parseJsonObjectOrError(text)
} catch (e: BricksHttpException) { } catch (e: BricksHttpException) {
val duration = Clock.System.now() - startTime val duration = Clock.System.now() - startTime
@ -143,16 +147,13 @@ class BricksHttp(private val context: BricksContext? = null) {
authToken: String = "" authToken: String = ""
): String { ): String {
val requestParams = emptyMap<String, String>().withBackendContextIfNeeded(url) val requestParams = emptyMap<String, String>().withBackendContextIfNeeded(url)
val requestUrl = url.withQueryParameters(requestParams) val devEntry = DevHttpInterceptor.recordRequest(url, "POST", form.entries.joinToString("&") { "${it.key}=${it.value}" })
val formBody = form.entries.joinToString("&") { (k, v) ->
"${k.encodeURLParameter()}=${v.encodeURLParameter()}"
}
val devEntry = DevHttpInterceptor.recordRequest(requestUrl, "POST", formBody)
val startTime = Clock.System.now() val startTime = Clock.System.now()
return try { return try {
val response = client.post(requestUrl) { val response = client.post(url) {
requestParams.forEach { (key, value) -> parameter(key, value) }
contentType(ContentType.Application.FormUrlEncoded) contentType(ContentType.Application.FormUrlEncoded)
setBody(formBody) setBody(Parameters.build { form.forEach { (k, v) -> append(k, v) } })
if (authToken.isNotEmpty()) { if (authToken.isNotEmpty()) {
header(HttpHeaders.Authorization, "Bearer $authToken") header(HttpHeaders.Authorization, "Bearer $authToken")
} }
@ -160,7 +161,7 @@ class BricksHttp(private val context: BricksContext? = null) {
val text = response.bodyAsText() val text = response.bodyAsText()
val duration = Clock.System.now() - startTime val duration = Clock.System.now() - startTime
DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds) DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds)
response.throwIfHttpError(text, requestUrl) response.throwIfHttpError(text, url)
text text
} catch (e: BricksHttpException) { } catch (e: BricksHttpException) {
val duration = Clock.System.now() - startTime val duration = Clock.System.now() - startTime
@ -183,11 +184,11 @@ class BricksHttp(private val context: BricksContext? = null) {
skipLog: Boolean = false skipLog: Boolean = false
): String { ): String {
val requestParams = params.withBackendContextIfNeeded(url) val requestParams = params.withBackendContextIfNeeded(url)
val requestUrl = url.withQueryParameters(requestParams) val devEntry = if (skipLog) null else DevHttpInterceptor.recordRequest(url, "GET")
val devEntry = if (skipLog) null else DevHttpInterceptor.recordRequest(requestUrl, "GET")
val startTime = Clock.System.now() val startTime = Clock.System.now()
return try { return try {
val response = client.get(requestUrl) { val response = client.get(url) {
requestParams.forEach { (key, value) -> parameter(key, value) }
if (authToken.isNotEmpty()) { if (authToken.isNotEmpty()) {
header(HttpHeaders.Authorization, "Bearer $authToken") header(HttpHeaders.Authorization, "Bearer $authToken")
} }
@ -197,7 +198,7 @@ class BricksHttp(private val context: BricksContext? = null) {
if (devEntry != null) { if (devEntry != null) {
DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds) DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds)
} }
response.throwIfHttpError(text, requestUrl) response.throwIfHttpError(text, url)
text text
} catch (e: BricksHttpException) { } catch (e: BricksHttpException) {
val duration = Clock.System.now() - startTime val duration = Clock.System.now() - startTime
@ -259,25 +260,6 @@ class BricksHttp(private val context: BricksContext? = null) {
private fun Map<String, String>.withBackendContextIfNeeded(url: String): Map<String, String> = private fun Map<String, String>.withBackendContextIfNeeded(url: String): Map<String, String> =
if (url.isWebBricksBackendResource()) withWebBricksRequestContext(this, requestContext) else this if (url.isWebBricksBackendResource()) withWebBricksRequestContext(this, requestContext) else this
private fun String.withQueryParameters(params: Map<String, String>): String {
if (params.isEmpty()) return this
val fragmentIndex = indexOf('#')
val baseAndQuery = if (fragmentIndex >= 0) substring(0, fragmentIndex) else this
val fragment = if (fragmentIndex >= 0) substring(fragmentIndex) else ""
val path = baseAndQuery.substringBefore('?')
val existingQuery = baseAndQuery.substringAfter('?', missingDelimiterValue = "")
val encodedOverrideKeys = params.keys.map { it.encodeURLParameter() }.toSet()
val preservedQuery = existingQuery
.split('&')
.filter { it.isNotBlank() }
.filter { entry -> entry.substringBefore('=').substringBefore('&') !in encodedOverrideKeys }
val appendedQuery = params.entries.map { (key, value) ->
"${key.encodeURLParameter()}=${value.encodeURLParameter()}"
}
val query = (preservedQuery + appendedQuery).joinToString("&")
return if (query.isBlank()) "$path$fragment" else "$path?$query$fragment"
}
private suspend fun HttpResponse.throwIfHttpError(body: String, requestUrl: String) { private suspend fun HttpResponse.throwIfHttpError(body: String, requestUrl: String) {
val code = status.value val code = status.value
if (code in 300..399 || code == 401 || code == 403 || code >= 400) { if (code in 300..399 || code == 401 || code == 403 || code >= 400) {

View File

@ -50,7 +50,20 @@ fun withWebBricksRequestContext(
return merged return merged
} }
/**
* Detect URLs that need WebBricks backend context (_webbricks_=1).
* Covers both direct .ui/.dspy paths and index fallback paths that the
* server resolves to .ui files (e.g. "/" index.ui, "/public" index.ui).
*/
fun String.isWebBricksBackendResource(): Boolean { fun String.isWebBricksBackendResource(): Boolean {
val path = substringBefore('?').substringBefore('#') val path = substringBefore('?').substringBefore('#')
return path.endsWith(".ui") || path.endsWith(".dspy") val normalized = path.trimEnd('/')
// Direct .ui/.dspy resource
if (path.endsWith(".ui") || path.endsWith(".dspy")) return true
// Index fallback paths resolved by the server to .ui files
if (normalized.isEmpty() || normalized == "/" || normalized == "/public") return true
return false
} }

37
test/generic-client/build.sh Executable file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
MODE="${1:-run}"
case "$MODE" in
run)
echo "=== Building and running generic-client ==="
./gradlew run --no-daemon
;;
build)
echo "=== Building generic-client ==="
./gradlew createDistributable --no-daemon
echo "Output: build/compose/binaries/main/"
;;
package)
echo "=== Packaging generic-client ==="
./gradlew packageDmg --no-daemon 2>/dev/null || \
./gradlew packageDeb --no-daemon 2>/dev/null || \
./gradlew packageMsi --no-daemon 2>/dev/null || \
echo "Note: Packaging requires the target OS (macOS/Windows/Linux)"
;;
clean)
echo "=== Cleaning ==="
./gradlew clean --no-daemon
;;
*)
echo "Usage: $0 {run|build|package|clean}"
echo " run - Build and run the app (default)"
echo " build - Create distributable app bundle"
echo " package - Create OS-specific installer (dmg/deb/msi)"
echo " clean - Clean build artifacts"
exit 1
;;
esac