/** * @stago/sdk/client — 业务前端运行时(api-only 模块的前端)。 * * 统一收口:logto access_token → 换 Stago JWT → 存储 → 带 Bearer 请求 → 401 懒刷新。 * 纯 Web API(fetch/localStorage),不依赖 Elysia/React,浏览器 bundle 零后端代码。 */ export { STAGO_LOGTO_API_RESOURCE } from './logto-constants.js'; export interface SessionUser { id: number; username: string; displayName: string | null; roles: string[]; } export interface StorageLike { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; } export interface StagoClientOptions { /** * API 基址(可含部署前缀),如 `https://api.zhangchuwang.cn/stago`。 * login → `{apiBaseUrl}{loginPath}`;业务请求 → `{apiBaseUrl}{prefix}{path}`。 */ apiBaseUrl: string; /** * 模块 api.prefix(与 stago.config.ts 一致),如 `/tasks`。 * fetch('/list') → `{apiBaseUrl}/tasks/list`。 */ prefix?: string; /** * 换票路径(相对 apiBaseUrl)。默认 `/auth/logto-callback`。 * 若网关仍挂在平台 `/api` 下,可显式设为 `/api/auth/logto-callback`。 */ loginPath?: string; /** * 取 logto access_token(须针对平台 API Resource)。 * 推荐 `logto.getAccessToken(STAGO_LOGTO_API_RESOURCE)`,并与 * `LogtoConfig.resources: [STAGO_LOGTO_API_RESOURCE]` 一致。 */ getAccessToken: () => Promise; /** 默认 localStorage,可注入(SSR/测试) */ storage?: StorageLike; /** 默认全局 fetch,可注入测试 */ fetch?: typeof globalThis.fetch; /** 请求超时,默认 30s */ timeout?: number; } /** 公开换票路径默认值(相对 apiBaseUrl,不含平台内部的 /api 前缀) */ export const DEFAULT_LOGIN_PATH = '/auth/logto-callback'; export interface StagoFetchInit extends Omit { /** query 参数,自动序列化拼到 URL */ query?: Record; /** json body,自动 stringify + 设 content-type */ json?: unknown; body?: BodyInit; } export interface StagoClient { login(): Promise; getToken(): string | null; getUser(): SessionUser | null; fetch(path: string, init?: StagoFetchInit): Promise; logout(): Promise; } const TOKEN_KEY = 'stago.token'; const USER_KEY = 'stago.user'; const DEFAULT_TIMEOUT = 30_000; export class StagoClientError extends Error { constructor(message: string) { super(message); this.name = 'StagoClientError'; } } export class AuthError extends StagoClientError { constructor(message: string, readonly code?: string) { super(message); this.name = 'AuthError'; } } export class ApiError extends StagoClientError { constructor( message: string, readonly status: number, readonly body?: unknown, ) { super(message); this.name = 'ApiError'; } } function defaultStorage(): StorageLike { // 浏览器环境兜底;SSR 下调用方应显式注入 storage const g = globalThis as { localStorage?: StorageLike }; if (g.localStorage) return g.localStorage; throw new StagoClientError('storage 未提供且无 localStorage(SSR 环境请显式注入 storage)'); } function joinBasePath(apiBaseUrl: string, path: string): string { const base = apiBaseUrl.replace(/\/+$/, ''); const p = path.startsWith('/') ? path : `/${path}`; return `${base}${p}`; } function buildUrl(apiBaseUrl: string, prefix: string | undefined, path: string, query?: StagoFetchInit['query']): string { const base = apiBaseUrl.replace(/\/+$/, ''); const pre = prefix ?? ''; const p = path.startsWith('/') ? path : `/${path}`; let url = `${base}${pre}${p}`; if (query) { const sp = new URLSearchParams(); for (const [k, v] of Object.entries(query)) { if (v === undefined) continue; sp.append(k, String(v)); } const qs = sp.toString(); if (qs) url += `?${qs}`; } return url; } interface CallbackOk { success: true; data: { user: SessionUser; token: string }; } interface CallbackErr { success: false; error: { code?: string; message?: string }; } export function createStagoClient(options: StagoClientOptions): StagoClient { const storage = options.storage ?? defaultStorage(); const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis); const timeout = options.timeout ?? DEFAULT_TIMEOUT; const loginPath = options.loginPath ?? DEFAULT_LOGIN_PATH; const apiBaseUrl = options.apiBaseUrl; let inflightLogin: Promise | null = null; async function login(): Promise { if (inflightLogin) return inflightLogin; inflightLogin = (async () => { let accessToken: string; try { accessToken = await options.getAccessToken(); } catch (e) { throw new AuthError('获取 logto access_token 失败', 'no_access_token'); } const res = await fetchFn(joinBasePath(apiBaseUrl, loginPath), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ accessToken }), }); if (!res.ok) { let code: string | undefined; let message: string | undefined; try { const body = (await res.json()) as CallbackErr; code = body?.error?.code; message = body?.error?.message; } catch { /* 非 json 响应 */ } throw new AuthError(message || '换 Stago JWT 失败', code ?? `http_${res.status}`); } const body = (await res.json()) as CallbackOk | CallbackErr; if (!body.success || !body.data) { const err = body as CallbackErr; throw new AuthError( err.error?.message || '换 Stago JWT 失败', err.error?.code ?? 'auth_failed', ); } storage.setItem(TOKEN_KEY, body.data.token); storage.setItem(USER_KEY, JSON.stringify(body.data.user)); return body.data.user; })().finally(() => { inflightLogin = null; }); return inflightLogin; } function getToken(): string | null { return storage.getItem(TOKEN_KEY); } function getUser(): SessionUser | null { const raw = storage.getItem(USER_KEY); if (!raw) return null; try { return JSON.parse(raw) as SessionUser; } catch { return null; } } async function logout(): Promise { storage.removeItem(TOKEN_KEY); storage.removeItem(USER_KEY); } async function rawFetch(path: string, init?: StagoFetchInit, isRetry = false): Promise { const url = buildUrl(apiBaseUrl, options.prefix, path, init?.query); const headers = new Headers(init?.headers); const token = getToken(); if (token) headers.set('authorization', `Bearer ${token}`); if (init?.json !== undefined) { headers.set('content-type', 'application/json'); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); let res: Response; try { res = await fetchFn(url, { ...init, headers, body: init?.json !== undefined ? JSON.stringify(init.json) : init?.body, signal: init?.signal ?? controller.signal, }); } catch (e) { clearTimeout(timer); throw new ApiError(e instanceof Error ? e.message : String(e), 0); } clearTimeout(timer); if (res.status === 401 && !isRetry) { await login(); return rawFetch(path, init, true); } if (res.status === 401 && isRetry) { throw new AuthError('刷新后仍 401', 'auth_failed'); } if (!res.ok && res.status >= 400) { let body: unknown; try { body = await res.json(); } catch { body = await res.text().catch(() => undefined); } throw new ApiError(`请求失败: ${res.status}`, res.status, body); } return res; } return { login, getToken, getUser, fetch: rawFetch, logout }; } export { TOKEN_KEY, USER_KEY };