65d7a57eb7
Project CI / Frontend tests (push) Successful in 4m0s
Project CI / Repository checks (push) Successful in 4m4s
Project CI / Backend tests (push) Successful in 8m40s
Project CI / Native shell tests (push) Successful in 17m56s
Project CI / Frontend tests (pull_request) Successful in 5m16s
Project CI / Repository checks (pull_request) Successful in 6m57s
Project CI / Backend tests (pull_request) Successful in 6m31s
Project CI / Native shell tests (pull_request) Successful in 16m57s
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/241 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
194 lines
5.8 KiB
TypeScript
194 lines
5.8 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';
|
|
|
|
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 } = {},
|
|
): 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);
|
|
if (target.transport === 'tauri-http') {
|
|
return tauriHttpFetch(target.url, markedInit);
|
|
}
|
|
return fetch(target.url, markedInit);
|
|
}
|