Skip to content

前后端通信通道

Fredica 前端与后端之间存在两条独立的通信通道:Route API(HTTP)和 kmpJsBridge(进程内 postMessage)。两者的传输方式、鉴权模型和适用场景均不同,开发时需根据场景选择正确的通道。

两种通道对比

Route APIkmpJsBridge
传输方式HTTP(localhost:7631)进程内 postMessage
鉴权方式Bearer Token进程内信任,无需 Token
可用环境WebView + 普通浏览器仅 WebView
响应方式HTTP 响应体(JSON)异步回调字符串(JSON)
典型用途所有业务 CRUD API敏感操作(凭据检测)、需绕过跨域的原生能力、Token 初始化前的配置读取

两种通道能够区分部署者与外部访问者,是因为 kmpJsBridge 仅在 composeApp 托管的 WebView 进程内可用——外部浏览器根本无法访问 Bridge,只能走 HTTP 路由。因此只需对 HTTP 路由做 Token 鉴权,即可在不影响部署者体验的前提下,将外部访问者隔离在授权边界之外。

Route API — HTTP 路由(Bearer Token)

前端通过 useAppFetch / apiFetch 发起的所有 HTTP 请求,均在 Authorization 头携带 Bearer Token:

Authorization: Bearer <token>

服务端 checkAuth()FredicaApi.jvm.kt)验证此 Token,失败时直接响应 401。Token 由用户在设置页配置,持久化到 localStorage,WebView 环境下通过 get_server_info Bridge 注入。

路由是否需要鉴权由 FredicaApi.Route.requiresAuth 控制(默认 true);图片代理等公开接口可设为 false

Kotlin 路由实现shared/src/commonMain/.../api/routes/):

kotlin
object WebenSourceListRoute : FredicaApi.Route {
    override val mode = FredicaApi.Route.Mode.Get
    override val desc = "来源库列表(可按 material_id 过滤,分页)"

    override suspend fun handler(param: String): ValidJsonString {
        // GET 路由:param 是 Map<String, List<String>> 的 JSON
        val query = param.loadJsonModel<Map<String, List<String>>>().getOrThrow()
        val materialId = query["material_id"]?.firstOrNull()
        val limit = query["limit"]?.firstOrNull()?.toIntOrNull() ?: 20
        val offset = query["offset"]?.firstOrNull()?.toIntOrNull() ?: 0

        val items = WebenSourceService.repo.listPaged(materialId, limit, offset)
        return AppUtil.dumpJsonStr(items).getOrThrow()
    }
}

前端调用fredica-webui/app/):

ts
// useAppFetch 自动附加 Bearer Token 和 host
const appFetch = useAppFetch();

const res = await appFetch("/api/v1/WebenSourceListRoute", {
    method: "GET",
    params: { material_id: materialId, limit: 20, offset: 0 },
});
if (!res.ok) { reportHttpError("加载来源列表失败", res); return; }
const data = await res.json() as PageResult;

kmpJsBridge — JS Bridge(进程内信任)

WebView 内的前端通过 window.kmpJsBridge.callNative(method, params, callback) 直接调用 Kotlin 原生方法,无需 Token

安全边界由运行环境保证:Bridge 仅在 composeApp 托管的 WebView 内可用,外部浏览器无法访问(callBridge() 会抛出 BridgeUnavailableError)。

方法名约定:Handler 类名去掉 JsMessageHandler 后缀,转为 lower_underscore。例如 CheckBilibiliCredentialJsMessageHandler → 方法名 check_bilibili_credential

Kotlin Handler 实现composeApp/src/commonMain/.../appwebview/messages/):

kotlin
// 类名决定方法名:CheckBilibiliCredential → "check_bilibili_credential"
class CheckBilibiliCredentialJsMessageHandler : MyJsMessageHandler() {

    override suspend fun handle2(
        message: JsMessage,
        navigator: WebViewNavigator?,
        callback: (String) -> Unit,
    ) {
        val params = message.params.loadJsonModel<CheckParams>().getOrElse { CheckParams() }

        // 敏感操作:凭据检测不通过 HTTP API 暴露,仅允许 Bridge 调用
        try {
            val raw = FredicaApi.PyUtil.post("/bilibili/credential/check", pyBody.str)
            callback(raw)  // 回调字符串会被基类自动转义(\ 和 ')
        } catch (e: Throwable) {
            logger.warn("CheckBilibiliCredential: Python 服务异常", err = e)
            callback(buildValidJson { kv("error", e.message ?: "unknown") }.str)
        }
    }
}

基类 MyJsMessageHandler 负责:在 Dispatchers.IO 上异步执行、统一 try-catch 兜底、对回调字符串中的 \' 进行转义(kmpJsBridge 将回调包裹在 JS 单引号字面量中,不转义会破坏 JS 语法)。

前端调用fredica-webui/app/util/bridge.ts):

ts
import { callBridge, callBridgeOrNull } from "~/util/bridge";

// 标准调用:bridge 不可用时抛出 BridgeUnavailableError
const raw = await callBridge(
    "check_bilibili_credential",
    JSON.stringify({ sessdata: "...", bili_jct: "..." }),
);
const result = JSON.parse(raw) as { configured: boolean; valid: boolean; message: string };
if (result.error) { print_error(result.error); return; }

// 静默变体:浏览器开发环境下 bridge 不可用时返回 null,无需 catch
const raw = await callBridgeOrNull("get_server_info");
if (!raw) return;  // 非 WebView 环境,静默跳过
const info = JSON.parse(raw) as ServerInfo;

callBridge 内置启动期重试(默认最多 5 次,间隔 300ms),处理 WebView 注入后 postMessage 通道尚未就绪的竞态问题。

ts
// fredica-webui/app/util/bridge.ts
/**
 * kmpJsBridge 安全封装。
 *
 * 问题根源:WebView 注入 window.kmpJsBridge 对象后,
 * 其内部 postMessage 通道需要额外数百毫秒才就绪。
 * 在此期间调用 callNative 会同步抛出
 * "window.kmpJsBridge.postMessage is not a function"。
 *
 * 解决方案:callBridge() 内置启动期重试,对所有调用方透明,
 * 调用方无需自行添加 try-catch 或重试逻辑。
 */

/** bridge 对象不存在(非 WebView 环境,如普通浏览器)时抛出此错误。 */
export class BridgeUnavailableError extends Error {
    constructor() {
        super("kmpJsBridge 不可用(非 WebView 环境)");
        this.name = "BridgeUnavailableError";
    }
}

/**
 * 安全调用 kmpJsBridge.callNative,返回 Promise<string>。
 *
 * - bridge 不可用(浏览器环境)→ 抛出 BridgeUnavailableError
 * - callNative 同步抛出(postMessage 通道未就绪)→ 自动重试,最多 maxRetries 次
 * - 超过最大重试次数 → 抛出最后一次错误
 *
 * @param method  bridge 方法名
 * @param params  JSON 字符串参数,默认 "{}"
 * @param options maxRetries(默认 5)/ retryDelayMs(默认 300ms)
 */
export async function callBridge(
    method: string,
    params: string = "{}",
    { maxRetries = 5, retryDelayMs = 300 }: { maxRetries?: number; retryDelayMs?: number } = {},
): Promise<string> {
    const bridge = typeof window !== "undefined" ? window.kmpJsBridge : undefined;
    if (!bridge) {
        console.debug(`[callBridge] bridge unavailable (non-WebView env), method=${method}`);
        throw new BridgeUnavailableError();
    }

    let lastError: unknown;
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        if (attempt > 0) {
            await new Promise<void>(r => setTimeout(r, retryDelayMs));
        }
        try {
            return await new Promise<string>((resolve, reject) => {
                try {
                    bridge.callNative(method, params, resolve);
                } catch (e) {
                    reject(e); // 同步抛出(bridge 未就绪)→ 触发重试
                }
            });
        } catch (e) {
            lastError = e;
        }
    }
    throw lastError;
}

/**
 * callBridge 的静默变体:bridge 不可用时返回 null,其他错误正常抛出。
 *
 * 适用于浏览器开发环境下可静默跳过的调用(如 useEffect 内的数据加载)。
 * 调用方只需 `if (!raw) return` 即可,无需 catch BridgeUnavailableError。
 */
export async function callBridgeOrNull(
    method: string,
    params: string = "{}",
    options?: { maxRetries?: number; retryDelayMs?: number },
): Promise<string | null> {
    try {
        return await callBridge(method, params, options ?? {});
    } catch (e) {
        if (e instanceof BridgeUnavailableError) return null;
        throw e;
    }
}

/**
 * 打开内部路由(应用内页面)。
 * - WebView 环境:调用 open_internal_tab bridge,在系统浏览器(Phase 1)或新 WebView 窗口(Phase 6)打开。
 * - 普通浏览器开发环境:window.open 新标签。
 */
export function openInternalUrl(path: string) {
    if (typeof window === "undefined") return;
    if (window.kmpJsBridge) {
        callBridge("open_internal_tab", JSON.stringify({ path })).catch(() => {});
    } else {
        window.open(path, "_blank", "noopener,noreferrer");
    }
}

/**
 * 打开外部 URL。
 * - WebView 环境(kmpJsBridge 存在):调用 open_browser bridge,由原生系统浏览器打开。
 * - 普通浏览器环境:直接 window.open。
 */
export function openExternalUrl(url: string) {
    if (typeof window === "undefined") return;
    if (window.kmpJsBridge) {
        callBridge("open_browser", JSON.stringify({ url, addServerInfoParam: false })).catch(() => {});
    } else {
        window.open(url, "_blank", "noopener,noreferrer");
    }
}

Fredica — AI 视频工坊