前后端通信通道
Fredica 前端与后端之间存在两条独立的通信通道:Route API(HTTP)和 kmpJsBridge(进程内 postMessage)。两者的传输方式、鉴权模型和适用场景均不同,开发时需根据场景选择正确的通道。
两种通道对比
| Route API | kmpJsBridge | |
|---|---|---|
| 传输方式 | 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/):
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/):
// 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/):
// 类名决定方法名: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):
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 通道尚未就绪的竞态问题。
// 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");
}
}