df39d5a16f
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
客户端 debug 保留服务器选择,正式包按发布渠道连接服务 修复错误报告时间解析与超时路由脱敏 重做后台错误详情面板并补齐定向测试 同步渠道、诊断与后台展示文档
377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
|
|
|
|
export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
|
|
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
|
|
export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client';
|
|
export const AGC_CLIENT_MARKER_VALUE = 'agc';
|
|
|
|
export type ClientServerPreset = 'release' | 'dev' | 'custom';
|
|
|
|
export type ClientServerSelection = {
|
|
preset: ClientServerPreset;
|
|
customBaseUrl: string;
|
|
};
|
|
|
|
const CLIENT_SERVER_SELECTION_STORAGE_KEY =
|
|
'genarrative.client.server-selection.v1';
|
|
const CLIENT_PLATFORM_CHANNEL =
|
|
import.meta.env.VITE_AGC_PLATFORM_CHANNEL?.trim() === 'release'
|
|
? 'release'
|
|
: 'dev';
|
|
|
|
/** 本地 Vite debug 才允许切换平台服务器;打包产物始终跟随构建渠道。 */
|
|
export const clientServerSelectionEnabled = import.meta.env.DEV;
|
|
|
|
export function isClientServerSelectionEnabled() {
|
|
const value = String(import.meta.env.DEV);
|
|
return value === 'true' || value === '1';
|
|
}
|
|
/**
|
|
* Upper bound for the initial network transaction (DNS/connect/response
|
|
* headers). Callers may override this for a request that legitimately needs
|
|
* more time; the default prevents auth/bootstrap requests from hanging
|
|
* forever when the platform service is unavailable.
|
|
*/
|
|
export const CLIENT_HTTP_DEFAULT_TIMEOUT_MS = 15_000;
|
|
|
|
export class ClientHttpTimeoutError extends Error {
|
|
readonly code = 'CLIENT_HTTP_TIMEOUT';
|
|
readonly timeoutMs: number;
|
|
readonly url: string;
|
|
|
|
constructor(url: string, timeoutMs: number) {
|
|
super(`请求超时(${timeoutMs} ms):${url}`);
|
|
this.name = 'ClientHttpTimeoutError';
|
|
this.timeoutMs = timeoutMs;
|
|
this.url = url;
|
|
}
|
|
}
|
|
|
|
export function isClientHttpTimeoutError(
|
|
error: unknown,
|
|
): error is ClientHttpTimeoutError {
|
|
return error instanceof ClientHttpTimeoutError;
|
|
}
|
|
|
|
/**
|
|
* Read a response body with the same bounded lifetime as the request that
|
|
* produced it. Some transports resolve fetch() after headers arrive while
|
|
* leaving body consumption pending indefinitely.
|
|
*/
|
|
export async function readClientHttpResponseText(
|
|
response: Response,
|
|
options: { timeoutMs?: number | null; url?: string } = {},
|
|
) {
|
|
const timeoutMs =
|
|
options.timeoutMs === undefined
|
|
? CLIENT_HTTP_DEFAULT_TIMEOUT_MS
|
|
: options.timeoutMs;
|
|
if (timeoutMs === null) {
|
|
return response.text();
|
|
}
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
throw new RangeError('响应体超时时间必须是大于 0 的有限数值');
|
|
}
|
|
|
|
let timedOut = false;
|
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
const bodyPromise = response.text();
|
|
// A transport may reject after cancel() unblocks the stream. The race owns
|
|
// the observable result, so keep the late rejection out of the global queue.
|
|
void bodyPromise.catch(() => undefined);
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
timeoutHandle = setTimeout(() => {
|
|
timedOut = true;
|
|
try {
|
|
void response.body?.cancel().catch(() => undefined);
|
|
} catch {
|
|
// Response doubles and older WebViews may not expose cancel().
|
|
}
|
|
reject(
|
|
new ClientHttpTimeoutError(options.url ?? 'response body', timeoutMs),
|
|
);
|
|
}, timeoutMs);
|
|
});
|
|
try {
|
|
return await Promise.race([bodyPromise, timeout]);
|
|
} catch (error) {
|
|
if (timedOut) {
|
|
throw new ClientHttpTimeoutError(
|
|
options.url ?? 'response body',
|
|
timeoutMs,
|
|
);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
|
|
}
|
|
}
|
|
|
|
function defaultClientServerPreset(): Exclude<ClientServerPreset, 'custom'> {
|
|
if (isClientServerSelectionEnabled()) return 'dev';
|
|
return CLIENT_PLATFORM_CHANNEL;
|
|
}
|
|
|
|
function isClientServerPreset(value: unknown): value is ClientServerPreset {
|
|
return value === 'release' || value === 'dev' || value === 'custom';
|
|
}
|
|
|
|
export function normalizeClientServerBaseUrl(value: string) {
|
|
const normalized = value.trim().replace(/\/+$/u, '');
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(normalized);
|
|
} catch {
|
|
throw new Error('服务器地址无效');
|
|
}
|
|
if (
|
|
!['http:', 'https:'].includes(parsed.protocol) ||
|
|
parsed.username ||
|
|
parsed.password ||
|
|
parsed.pathname !== '/' ||
|
|
parsed.search ||
|
|
parsed.hash
|
|
) {
|
|
throw new Error('服务器地址必须是纯 HTTP(S) 地址');
|
|
}
|
|
const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(
|
|
parsed.hostname,
|
|
);
|
|
if (parsed.protocol === 'http:' && !isLoopback) {
|
|
throw new Error('非本机服务器必须使用 HTTPS');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function readStoredClientServerSelection(): ClientServerSelection {
|
|
const fallback: ClientServerSelection = {
|
|
preset: defaultClientServerPreset(),
|
|
customBaseUrl: '',
|
|
};
|
|
if (!isClientServerSelectionEnabled() || typeof window === 'undefined') {
|
|
return fallback;
|
|
}
|
|
try {
|
|
const raw = window.localStorage.getItem(
|
|
CLIENT_SERVER_SELECTION_STORAGE_KEY,
|
|
);
|
|
if (!raw) return fallback;
|
|
const parsed = JSON.parse(raw) as {
|
|
preset?: unknown;
|
|
customBaseUrl?: unknown;
|
|
};
|
|
if (!isClientServerPreset(parsed.preset)) return fallback;
|
|
const customBaseUrl =
|
|
typeof parsed.customBaseUrl === 'string' ? parsed.customBaseUrl : '';
|
|
if (parsed.preset === 'custom') {
|
|
normalizeClientServerBaseUrl(customBaseUrl);
|
|
}
|
|
return { preset: parsed.preset, customBaseUrl };
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
export function getClientServerSelection() {
|
|
return readStoredClientServerSelection();
|
|
}
|
|
|
|
export function setClientServerSelection(
|
|
selection: ClientServerSelection,
|
|
): ClientServerSelection {
|
|
const next: ClientServerSelection = {
|
|
preset: selection.preset,
|
|
customBaseUrl:
|
|
selection.preset === 'custom'
|
|
? normalizeClientServerBaseUrl(selection.customBaseUrl)
|
|
: selection.customBaseUrl.trim(),
|
|
};
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.setItem(
|
|
CLIENT_SERVER_SELECTION_STORAGE_KEY,
|
|
JSON.stringify(next),
|
|
);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
export function resetClientServerSelectionForTests() {
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.removeItem(CLIENT_SERVER_SELECTION_STORAGE_KEY);
|
|
}
|
|
}
|
|
|
|
function getChannelServerBaseUrl() {
|
|
return CLIENT_PLATFORM_CHANNEL === 'release'
|
|
? AGC_RELEASE_API_BASE_URL
|
|
: AGC_DEVELOPMENT_API_BASE_URL;
|
|
}
|
|
|
|
export function getClientServerBaseUrl(selection?: ClientServerSelection) {
|
|
const resolved =
|
|
selection ??
|
|
(isClientServerSelectionEnabled() ? getClientServerSelection() : null);
|
|
if (!resolved) return getChannelServerBaseUrl();
|
|
if (resolved.preset === 'release') return AGC_RELEASE_API_BASE_URL;
|
|
if (resolved.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL;
|
|
return normalizeClientServerBaseUrl(resolved.customBaseUrl);
|
|
}
|
|
|
|
type ClientHttpContext = {
|
|
isTauri: boolean;
|
|
mode?: string;
|
|
serverBaseUrl?: string;
|
|
};
|
|
|
|
type ClientHttpTarget = {
|
|
transport: 'web' | 'tauri-http';
|
|
url: string;
|
|
};
|
|
|
|
function withAgcClientMarker(init: RequestInit): RequestInit {
|
|
const headers = new Headers(init.headers);
|
|
headers.set(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE);
|
|
return { ...init, headers };
|
|
}
|
|
|
|
function currentClientHttpContext(): ClientHttpContext {
|
|
return {
|
|
isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__),
|
|
mode: import.meta.env.MODE,
|
|
};
|
|
}
|
|
|
|
export function resolveClientHttpTarget(
|
|
url: string,
|
|
context: ClientHttpContext = currentClientHttpContext(),
|
|
): ClientHttpTarget {
|
|
// Unit fixtures use relative requests after the same origin validation.
|
|
if (context.mode === 'test' && !context.serverBaseUrl) {
|
|
return { transport: 'web', url };
|
|
}
|
|
const serverBaseUrl = context.serverBaseUrl ?? getClientServerBaseUrl();
|
|
if (
|
|
!isClientServerSelectionEnabled() &&
|
|
serverBaseUrl !== getChannelServerBaseUrl()
|
|
) {
|
|
throw new Error('请求目标不在当前构建渠道的服务器范围内');
|
|
}
|
|
const target = new URL(url, `${serverBaseUrl}/`);
|
|
if (
|
|
(context.serverBaseUrl && context.serverBaseUrl !== serverBaseUrl) ||
|
|
target.origin !== serverBaseUrl ||
|
|
target.username ||
|
|
target.password
|
|
) {
|
|
throw new Error('请求目标不在当前构建渠道的服务器范围内');
|
|
}
|
|
|
|
if (context.mode === 'test') {
|
|
return { transport: 'web', url };
|
|
}
|
|
|
|
if (!context.isTauri) {
|
|
return { transport: 'web', url: target.toString() };
|
|
}
|
|
return { transport: 'tauri-http', url: target.toString() };
|
|
}
|
|
|
|
export async function fetchClientHttp(
|
|
url: string,
|
|
init: RequestInit,
|
|
options: {
|
|
serverBaseUrl?: string;
|
|
/** Set to null to opt out for a long-running request. */
|
|
timeoutMs?: number | null;
|
|
} = {},
|
|
): Promise<Response> {
|
|
const currentContext = currentClientHttpContext();
|
|
const target = resolveClientHttpTarget(url, {
|
|
...currentContext,
|
|
serverBaseUrl: options.serverBaseUrl,
|
|
});
|
|
const markedInit = withAgcClientMarker(init);
|
|
|
|
// Always use a private controller so an internal timeout cannot mutate a
|
|
// caller-owned AbortSignal. The caller's signal is still propagated in
|
|
// both directions, preserving normal AbortError behaviour for user aborts.
|
|
const timeoutMs =
|
|
options.timeoutMs === undefined
|
|
? CLIENT_HTTP_DEFAULT_TIMEOUT_MS
|
|
: options.timeoutMs;
|
|
if (timeoutMs === null) {
|
|
if (target.transport === 'tauri-http') {
|
|
return tauriHttpFetch(target.url, markedInit);
|
|
}
|
|
return fetch(target.url, markedInit);
|
|
}
|
|
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
throw new RangeError('请求超时时间必须是大于 0 的有限数值');
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
let timedOut = false;
|
|
const callerSignal = markedInit.signal;
|
|
const forwardCallerAbort = () => {
|
|
// AbortSignal.reason is available in modern browsers/Tauri WebViews. The
|
|
// fallback keeps compatibility with older runtimes and test doubles.
|
|
const reason = callerSignal?.reason;
|
|
try {
|
|
controller.abort(reason);
|
|
} catch {
|
|
controller.abort();
|
|
}
|
|
};
|
|
if (callerSignal) {
|
|
if (callerSignal.aborted) {
|
|
forwardCallerAbort();
|
|
} else {
|
|
callerSignal.addEventListener('abort', forwardCallerAbort, {
|
|
once: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
const requestInit = { ...markedInit, signal: controller.signal };
|
|
let request: Promise<Response>;
|
|
try {
|
|
// Keep invocation synchronous so an already-aborted caller signal is
|
|
// observed by transports that only subscribe to `abort` events.
|
|
const responsePromise =
|
|
target.transport === 'tauri-http'
|
|
? tauriHttpFetch(target.url, requestInit)
|
|
: fetch(target.url, requestInit);
|
|
request = Promise.resolve(responsePromise);
|
|
} catch (error) {
|
|
callerSignal?.removeEventListener('abort', forwardCallerAbort);
|
|
throw error;
|
|
}
|
|
// A timed-out request is intentionally not awaited after the race settles,
|
|
// but transports may still reject when the abort reaches them. Attach a
|
|
// sink to avoid an unhandled rejection while keeping the original promise
|
|
// in the race for normal errors.
|
|
void request.catch(() => undefined);
|
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
timeoutHandle = setTimeout(() => {
|
|
timedOut = true;
|
|
controller.abort();
|
|
reject(new ClientHttpTimeoutError(target.url, timeoutMs));
|
|
}, timeoutMs);
|
|
});
|
|
try {
|
|
return await Promise.race([request, timeout]);
|
|
} catch (error) {
|
|
// Some transports reject with a generic error after AbortController.abort;
|
|
// expose a stable, actionable error to auth/bootstrap callers.
|
|
if (timedOut) {
|
|
throw new ClientHttpTimeoutError(target.url, timeoutMs);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
|
|
callerSignal?.removeEventListener('abort', forwardCallerAbort);
|
|
}
|
|
}
|