bd0f566e76
将原生壳请求默认超时和最大超时提升到共享 HostBridge 契约 让 H5 nativeAppHostBridge 复用共享超时边界 增加原生壳门禁拦截本地超时边界重声明 补充原生壳方案和共享决策记录
326 lines
7.9 KiB
TypeScript
326 lines
7.9 KiB
TypeScript
import {
|
|
HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS,
|
|
HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS,
|
|
HOST_BRIDGE_PROTOCOL,
|
|
HOST_BRIDGE_TAURI_COMMAND,
|
|
HOST_BRIDGE_VERSION,
|
|
type HostBridgeError,
|
|
type HostBridgeEvent,
|
|
type HostBridgeEventName,
|
|
type HostBridgeMethod,
|
|
type HostBridgeRequest,
|
|
type HostBridgeResponse,
|
|
isHostBridgeEventName,
|
|
} from '../../../packages/shared/src/contracts/hostBridge';
|
|
|
|
type NativeAppBridgeWindow = Window & {
|
|
ReactNativeWebView?: {
|
|
postMessage?: (message: string) => void;
|
|
};
|
|
__TAURI__?: {
|
|
core?: {
|
|
invoke?: <Result = unknown>(
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
) => Promise<Result>;
|
|
};
|
|
};
|
|
};
|
|
|
|
type PendingNativeRequest = {
|
|
resolve: (value: unknown) => void;
|
|
reject: (error: Error) => void;
|
|
timeoutId: ReturnType<typeof setTimeout>;
|
|
};
|
|
|
|
type NativeTauriInvoke = <Result = unknown>(
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
) => Promise<Result>;
|
|
|
|
const pendingNativeRequests = new Map<string, PendingNativeRequest>();
|
|
const nativeEventListeners = new Map<string, Set<(payload: unknown) => void>>();
|
|
|
|
let nativeBridgeListenerInstalled = false;
|
|
let nextNativeRequestSequence = 0;
|
|
|
|
function resolveNativeWindow() {
|
|
return typeof window === 'undefined'
|
|
? null
|
|
: (window as NativeAppBridgeWindow);
|
|
}
|
|
|
|
function buildNativeRequestId(method: HostBridgeMethod) {
|
|
nextNativeRequestSequence += 1;
|
|
return `host_${method.replace(/[^a-z0-9]/gi, '_')}_${Date.now()}_${nextNativeRequestSequence}`;
|
|
}
|
|
|
|
function resolveTimeoutMs(timeoutMs: number | undefined) {
|
|
if (!Number.isFinite(timeoutMs ?? Number.NaN) || !timeoutMs) {
|
|
return HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS;
|
|
}
|
|
|
|
return Math.min(
|
|
Math.max(1, Math.trunc(timeoutMs)),
|
|
HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS,
|
|
);
|
|
}
|
|
|
|
function createHostBridgeError(error: HostBridgeError) {
|
|
const result = new Error(error.message);
|
|
result.name = error.code;
|
|
return result;
|
|
}
|
|
|
|
function createNativeHostBridgeTimeoutError() {
|
|
return createHostBridgeError({
|
|
code: 'timeout',
|
|
message: 'host_bridge_timeout',
|
|
});
|
|
}
|
|
|
|
function isHostBridgeResponse(value: unknown): value is HostBridgeResponse {
|
|
if (!value || typeof value !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
const candidate = value as Partial<HostBridgeResponse>;
|
|
return (
|
|
candidate.bridge === HOST_BRIDGE_PROTOCOL &&
|
|
candidate.version === HOST_BRIDGE_VERSION &&
|
|
typeof candidate.id === 'string' &&
|
|
typeof candidate.ok === 'boolean'
|
|
);
|
|
}
|
|
|
|
function isHostBridgeEvent(value: unknown): value is HostBridgeEvent {
|
|
if (!value || typeof value !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
const candidate = value as Partial<HostBridgeEvent>;
|
|
return (
|
|
candidate.bridge === HOST_BRIDGE_PROTOCOL &&
|
|
candidate.version === HOST_BRIDGE_VERSION &&
|
|
isHostBridgeEventName(candidate.event)
|
|
);
|
|
}
|
|
|
|
function parseNativeMessage(data: unknown) {
|
|
if (typeof data === 'string') {
|
|
try {
|
|
return JSON.parse(data) as unknown;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
function settleNativeResponse(response: HostBridgeResponse) {
|
|
const pending = pendingNativeRequests.get(response.id);
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
|
|
pendingNativeRequests.delete(response.id);
|
|
clearTimeout(pending.timeoutId);
|
|
|
|
if (response.ok) {
|
|
pending.resolve(response.result);
|
|
return;
|
|
}
|
|
|
|
pending.reject(createHostBridgeError(response.error));
|
|
}
|
|
|
|
function dispatchNativeEvent(event: HostBridgeEvent) {
|
|
const listeners = nativeEventListeners.get(event.event);
|
|
if (!listeners) {
|
|
return;
|
|
}
|
|
|
|
for (const listener of listeners) {
|
|
listener(event.payload);
|
|
}
|
|
}
|
|
|
|
function isNativeInjectedMessageEvent(event: MessageEvent) {
|
|
const nativeWindow = resolveNativeWindow();
|
|
if (!nativeWindow) {
|
|
return false;
|
|
}
|
|
|
|
if (event.source && event.source !== nativeWindow) {
|
|
return false;
|
|
}
|
|
|
|
if (event.origin && event.origin !== nativeWindow.location.origin) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function ensureNativeBridgeListener() {
|
|
const nativeWindow = resolveNativeWindow();
|
|
if (!nativeWindow || nativeBridgeListenerInstalled) {
|
|
return;
|
|
}
|
|
|
|
nativeWindow.addEventListener('message', (event) => {
|
|
if (!isNativeInjectedMessageEvent(event)) {
|
|
return;
|
|
}
|
|
|
|
const value = parseNativeMessage(event.data);
|
|
if (isHostBridgeResponse(value)) {
|
|
settleNativeResponse(value);
|
|
return;
|
|
}
|
|
if (isHostBridgeEvent(value)) {
|
|
dispatchNativeEvent(value);
|
|
}
|
|
});
|
|
|
|
nativeBridgeListenerInstalled = true;
|
|
}
|
|
|
|
async function invokeTauriHostBridgeWithTimeout<Result>(
|
|
tauriInvoke: NativeTauriInvoke,
|
|
request: HostBridgeRequest,
|
|
timeoutMs: number,
|
|
) {
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
timeoutId = setTimeout(() => {
|
|
reject(createNativeHostBridgeTimeoutError());
|
|
}, timeoutMs);
|
|
});
|
|
|
|
try {
|
|
return await Promise.race([
|
|
tauriInvoke<HostBridgeResponse<Result>>(HOST_BRIDGE_TAURI_COMMAND, {
|
|
request,
|
|
}),
|
|
timeoutPromise,
|
|
]);
|
|
} finally {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function canUseReactNativeHostBridge() {
|
|
return (
|
|
typeof resolveNativeWindow()?.ReactNativeWebView?.postMessage ===
|
|
'function'
|
|
);
|
|
}
|
|
|
|
export function canUseTauriHostBridge() {
|
|
return (
|
|
typeof resolveNativeWindow()?.__TAURI__?.core?.invoke === 'function'
|
|
);
|
|
}
|
|
|
|
export function canUseNativeAppHostBridge() {
|
|
return canUseReactNativeHostBridge() || canUseTauriHostBridge();
|
|
}
|
|
|
|
export function subscribeNativeAppHostBridgeEvent<Payload = unknown>(
|
|
eventName: HostBridgeEventName,
|
|
listener: (payload: Payload | undefined) => void,
|
|
) {
|
|
ensureNativeBridgeListener();
|
|
|
|
const listeners = nativeEventListeners.get(eventName) ?? new Set();
|
|
listeners.add(listener as (payload: unknown) => void);
|
|
nativeEventListeners.set(eventName, listeners);
|
|
|
|
return () => {
|
|
listeners.delete(listener as (payload: unknown) => void);
|
|
if (listeners.size === 0) {
|
|
nativeEventListeners.delete(eventName);
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function requestNativeAppHostBridge<Result = unknown>(
|
|
method: HostBridgeMethod,
|
|
payload?: unknown,
|
|
options: {
|
|
timeoutMs?: number;
|
|
} = {},
|
|
) {
|
|
const nativeWindow = resolveNativeWindow();
|
|
if (!nativeWindow) {
|
|
return null;
|
|
}
|
|
|
|
const timeoutMs = resolveTimeoutMs(options.timeoutMs);
|
|
const request: HostBridgeRequest = {
|
|
bridge: HOST_BRIDGE_PROTOCOL,
|
|
version: HOST_BRIDGE_VERSION,
|
|
id: buildNativeRequestId(method),
|
|
method,
|
|
timeoutMs,
|
|
...(payload === undefined ? {} : { payload }),
|
|
};
|
|
|
|
const tauriInvoke = nativeWindow.__TAURI__?.core?.invoke;
|
|
if (typeof tauriInvoke === 'function') {
|
|
const response = await invokeTauriHostBridgeWithTimeout<Result>(
|
|
tauriInvoke,
|
|
request,
|
|
timeoutMs,
|
|
);
|
|
if (!isHostBridgeResponse(response)) {
|
|
throw new Error('host_bridge_invalid_response');
|
|
}
|
|
if (response.ok) {
|
|
return response.result as Result;
|
|
}
|
|
throw createHostBridgeError(response.error);
|
|
}
|
|
|
|
const postMessage = nativeWindow.ReactNativeWebView?.postMessage;
|
|
if (typeof postMessage !== 'function') {
|
|
return null;
|
|
}
|
|
|
|
ensureNativeBridgeListener();
|
|
|
|
return await new Promise<Result>((resolve, reject) => {
|
|
const timeoutId = setTimeout(() => {
|
|
pendingNativeRequests.delete(request.id);
|
|
reject(createNativeHostBridgeTimeoutError());
|
|
}, timeoutMs);
|
|
|
|
pendingNativeRequests.set(request.id, {
|
|
resolve: (value) => resolve(value as Result),
|
|
reject,
|
|
timeoutId,
|
|
});
|
|
|
|
try {
|
|
postMessage(JSON.stringify(request));
|
|
} catch (error) {
|
|
pendingNativeRequests.delete(request.id);
|
|
clearTimeout(timeoutId);
|
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
});
|
|
}
|
|
|
|
export function resetNativeAppHostBridgeForTest() {
|
|
for (const pending of pendingNativeRequests.values()) {
|
|
clearTimeout(pending.timeoutId);
|
|
}
|
|
pendingNativeRequests.clear();
|
|
nativeEventListeners.clear();
|
|
nextNativeRequestSequence = 0;
|
|
}
|