Files
Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts
T
kdletters 991b2a1104
Project CI / Repository checks (push) Successful in 3m43s
Project CI / Frontend tests (push) Successful in 3m54s
Project CI / Backend tests (push) Successful in 6m16s
Project CI / Native shell tests (push) Successful in 20m0s
修复 AGC 异步操作恢复闭环 (#346)
## 变更内容

AGC 登录、Runner 会话、最近项目检查和首页自动创建原先各自维护异步状态,响应体卡住、单目录变慢或切页会导致按钮长期 busy、项目列表整体不可用或重复创建项目。本 PR 将这些入口收口到可恢复的生命周期边界:

- 认证响应体读取增加独立超时,refresh singleflight 在失败后释放;
- Runner 会话安装/清除移到 blocking worker,登录/退出增加 45 秒 UI fence;
- 最近项目逐项检查并设置单项目超时,已完成行不受其它慢目录阻塞;
- 首页自动创建锁提升到 WorkspaceLauncher,切页后仍防重并保留状态;
- 新增异步闭环技术方案、body 卡住测试、逐行项目测试和跨页创建测试。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/clientHttp.test.ts tests/clientApi.test.ts tests/recentProjectsModel.test.ts --reporter=dot`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/appSurface.test.ts --reporter=dot`
- `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
- `npm run check:doc-index`
- `npm run check:encoding`
- `git diff --check`

`appSurface.test.ts` 390/390 通过;保留仓库既有 act/jsdom media warning。本 PR 未触发真实 Provider 或发布安装包。

Reviewed-on: #346
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-14 13:19:13 +08:00

356 lines
11 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';
/**
* 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 selected server or proxy 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);
}
}
export type ClientServerPreset = 'release' | 'dev' | 'custom';
export type ClientServerSelection = {
preset: ClientServerPreset;
customBaseUrl: string;
};
const CLIENT_SERVER_SELECTION_STORAGE_KEY =
'genarrative.client.server-selection.v1';
function defaultClientServerPreset(): Exclude<ClientServerPreset, 'custom'> {
return import.meta.env.DEV ? 'dev' : 'release';
}
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 (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);
}
}
export function getClientServerBaseUrl(
selection: ClientServerSelection = getClientServerSelection(),
) {
if (selection.preset === 'release') return AGC_RELEASE_API_BASE_URL;
if (selection.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL;
return normalizeClientServerBaseUrl(selection.customBaseUrl);
}
type ClientHttpContext = {
isDevelopment: boolean;
isTauri: boolean;
pageProtocol: string;
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 {
isDevelopment: import.meta.env.DEV,
isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__),
pageProtocol: typeof window === 'undefined' ? '' : window.location.protocol,
mode: import.meta.env.MODE,
};
}
export function resolveClientHttpTarget(
url: string,
context: ClientHttpContext = currentClientHttpContext(),
): ClientHttpTarget {
// Existing unit fixtures omit mode; retain the Vite-relative transport for
// them while real development/release clients use the selected server.
if (
!context.serverBaseUrl &&
(context.mode === 'test' || (!context.mode && context.isDevelopment))
) {
return { transport: 'web', url };
}
const serverBaseUrl =
context.serverBaseUrl ?? getClientServerBaseUrl(getClientServerSelection());
const target = new URL(url, `${serverBaseUrl}/`);
if (target.origin !== serverBaseUrl) {
throw new Error('请求目标不在当前选择的服务器范围内');
}
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 serverBaseUrl = options.serverBaseUrl
? normalizeClientServerBaseUrl(options.serverBaseUrl)
: undefined;
// Unit fixtures intentionally use the relative Vite transport. Real clients bind every
// auth transaction to the explicit origin captured before its first request.
const target = resolveClientHttpTarget(
url,
currentContext.mode === 'test'
? currentContext
: { ...currentContext, 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);
}
}