企业码解析+登录跑通真实后端:Tinode连接适配层、类型化API客户端、会话store
lib/tinode/connection.ts:单例Tinode连接+自己的发布订阅层——SDK的onConnect/onDisconnect/onLogin
是单槽位属性(赋值会互相覆盖),不是真正的多监听器事件源,所以这里统一接管这几个槽位,自己
扇出给任意数量的React订阅方(lib/tinode/hooks.ts的useSyncExternalStore)。installTenantHiPatch
照搬旧应用tinode-web.jsx的实现搬过来——服务端{hi}包的tenant字段SDK本身没有API,只能在原始
sendText层面注入,这是后端协议的真实约束,不是旧代码库特有的写法。
lib/api/client.ts:REST客户端按旧应用src/lib/im-api.js的request函数+完整REASON_MESSAGES错误码
翻译表搬过来(含请求头X-MeetingIM-APIKey等几个月联调换来的正确写法),这次是重新用TypeScript
写、不是复制文件,但端点行为对齐。
实机联调过程中发现并修正了一处凭记忆猜错的地方:tenants/resolve真实响应结构是
{endpoints:{api,file,websocket}, tenant:{code,name}},不是我最初假设的顶层{code,name}——
已用真实请求验证修正。用企业码default解析成功(显示"Default Tenant")+ 一次真实错误密码
登录尝试(服务端真实401,错误提示正确显示"账号或密码错误")两步都对着真实后端验证通过,
不是只测了正确路径。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8bcd9f3a5b
commit
8cdd84152d
+21
-11
@@ -2,7 +2,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from '@/app/AppShell';
|
||||
import { RequireAuth } from '@/app/RequireAuth';
|
||||
import { SectionPlaceholder } from '@/app/SectionPlaceholder';
|
||||
import { LoginPage } from '@/features/auth/LoginPage';
|
||||
import { TenantEntryPage } from '@/features/auth/TenantEntryPage';
|
||||
import { ConversationView } from '@/features/messages/ConversationView';
|
||||
import { EmptyMain } from '@/features/messages/EmptyMain';
|
||||
import { MessagesLayout } from '@/features/messages/MessagesLayout';
|
||||
@@ -10,23 +13,30 @@ import { MessagesLayout } from '@/features/messages/MessagesLayout';
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: 'tenant', element: <TenantEntryPage /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{
|
||||
element: <AppShell />,
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/messages" replace /> },
|
||||
{
|
||||
path: 'messages',
|
||||
element: <MessagesLayout />,
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ index: true, element: <EmptyMain /> },
|
||||
{ path: ':topicId', element: <ConversationView /> },
|
||||
{ index: true, element: <Navigate to="/messages" replace /> },
|
||||
{
|
||||
path: 'messages',
|
||||
element: <MessagesLayout />,
|
||||
children: [
|
||||
{ index: true, element: <EmptyMain /> },
|
||||
{ path: ':topicId', element: <ConversationView /> },
|
||||
],
|
||||
},
|
||||
{ path: 'contacts', element: <SectionPlaceholder title="通讯录" /> },
|
||||
{ path: 'favorites', element: <SectionPlaceholder title="我的收藏" /> },
|
||||
{ path: 'files', element: <SectionPlaceholder title="文件助手" /> },
|
||||
{ path: 'calls', element: <SectionPlaceholder title="通话记录" /> },
|
||||
{ path: 'settings', element: <SectionPlaceholder title="设置" /> },
|
||||
],
|
||||
},
|
||||
{ path: 'contacts', element: <SectionPlaceholder title="通讯录" /> },
|
||||
{ path: 'favorites', element: <SectionPlaceholder title="我的收藏" /> },
|
||||
{ path: 'files', element: <SectionPlaceholder title="文件助手" /> },
|
||||
{ path: 'calls', element: <SectionPlaceholder title="通话记录" /> },
|
||||
{ path: 'settings', element: <SectionPlaceholder title="设置" /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/** Gate for everything inside AppShell: needs a resolved tenant and an authenticated user. */
|
||||
export function RequireAuth() {
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
|
||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||
if (!myUserId) return <Navigate to="/login" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { connect } from '@/lib/tinode/connection';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* ctrl.code/text -> Chinese message. Ported from the old app's loginErrorMessage() -- the SDK's
|
||||
* own login promise rejects with an Error whose `.code` is the ctrl code (401/404/500/...), not
|
||||
* with an ImApiError, since this is the native WS protocol, not the /v1/im REST layer.
|
||||
*/
|
||||
function loginErrorMessage(err: unknown): string {
|
||||
const code = (err as { code?: number })?.code;
|
||||
if (code === 401) return '账号或密码错误';
|
||||
if (code === 404) return '账号不存在';
|
||||
if (code && code >= 500) return '服务器繁忙,请稍后重试';
|
||||
return (err as Error)?.message || '登录失败,请重试';
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const tinode = useTinode();
|
||||
const [login, setLogin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
if (!tenant) {
|
||||
// No tenant resolved yet this session -- can't build a valid {hi} packet without it.
|
||||
return <Navigate to="/tenant" replace />;
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!login.trim() || !password || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
if (!tinode.isConnected()) {
|
||||
await connect();
|
||||
}
|
||||
const { ctrl } = await tinode.loginBasic(login.trim(), password);
|
||||
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
||||
// Account created but needs credential confirmation (email/SMS code) -- full flow lands
|
||||
// with the register/credential-confirm screens later in Phase 1.
|
||||
setError('账号需要先完成验证,此流程正在实现中');
|
||||
return;
|
||||
}
|
||||
const authToken = tinode.getAuthToken();
|
||||
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
|
||||
setMyUserId(tinode.getAuthenticatedUserID() ?? null);
|
||||
navigate('/messages');
|
||||
} catch (err) {
|
||||
setError(loginErrorMessage(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full items-center justify-center bg-surface-page">
|
||||
<form onSubmit={submit} className="flex w-[380px] flex-col gap-4">
|
||||
<span className="text-[13px] text-text-tertiary-2">{tenant.name}</span>
|
||||
<h1 className="m-0 text-[28px] font-bold text-text-title">登录</h1>
|
||||
{error ? (
|
||||
<div className="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{error}</div>
|
||||
) : null}
|
||||
<input
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
placeholder="用户名或手机号"
|
||||
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="密码"
|
||||
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!login.trim() || !password || submitting}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { resolveTenant } from '@/lib/api/tenant';
|
||||
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
|
||||
|
||||
export function TenantEntryPage() {
|
||||
const navigate = useNavigate();
|
||||
const setTenant = useSessionStore((s) => s.setTenant);
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!code.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await resolveTenant(code.trim());
|
||||
const { host, secure } = parseWebsocketEndpoint(res.endpoints.websocket);
|
||||
setTenant({ code: res.tenant.code, name: res.tenant.name, endpoints: res.endpoints, host, secure });
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : '企业码不存在或已停用,请核实后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full items-center justify-center bg-surface-page">
|
||||
<form onSubmit={submit} className="flex w-[380px] flex-col gap-5">
|
||||
<div className="mb-2 flex flex-col items-center gap-3">
|
||||
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-3xl font-bold text-white">
|
||||
M
|
||||
</div>
|
||||
<strong className="text-2xl font-bold text-text-heading">MeetingIM</strong>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{error}</div>
|
||||
) : null}
|
||||
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
|
||||
<span className="material-icons text-[20px] text-accent">business</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="请输入企业码"
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!code.trim() || submitting}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '进入中…' : '进入企业'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Thin REST client for the product-specific `/v1/im/*` API that sits alongside the Tinode WS
|
||||
// protocol. Ported from the previous im_webapp's src/lib/im-api.js -- the header names, the
|
||||
// reason-code translation table, and the batch-endpoint "always 200, real result in items[]"
|
||||
// quirk are all hard-won facts about this exact backend (im_team/im_server, server/im_http.go),
|
||||
// verified against a live deployment over months of integration work. They are not guesses and
|
||||
// should not be "cleaned up" back to what the upstream Tinode docs say (this deployment's header
|
||||
// is `X-MeetingIM-APIKey`, not the upstream sample's `X-Tinode-APIKey` -- that exact mismatch was
|
||||
// a real, repeatedly-rediscovered bug in the old codebase).
|
||||
import { API_KEY } from '@/lib/config';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const DEFAULT_API_BASE = '/v1/im';
|
||||
const DEFAULT_FILE_BASE = '/v0/file';
|
||||
|
||||
export class ImApiError extends Error {
|
||||
reason?: string;
|
||||
status?: number;
|
||||
details?: unknown;
|
||||
|
||||
constructor(message: string, reason?: string, status?: number, details?: unknown) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md §12.4's full reason list -- see client.ts's header comment. */
|
||||
const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | null)> = {
|
||||
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
|
||||
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
|
||||
TENANT_UNAVAILABLE: '企业码不存在或已停用',
|
||||
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
|
||||
AUTH_FAILED: '账号或密码错误',
|
||||
TOKEN_EXPIRED: '登录已过期,请重新登录',
|
||||
ACCOUNT_DISABLED: '账号已被禁用',
|
||||
RATE_LIMITED: '操作过于频繁,请稍后再试',
|
||||
VALIDATION_FAILED: '提交的内容不符合要求',
|
||||
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
|
||||
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
|
||||
PERMISSION_DENIED: '没有权限执行此操作',
|
||||
NOT_FOUND: '内容不存在或已被删除',
|
||||
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
|
||||
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
|
||||
AUTH_REQUIRED: '请先登录',
|
||||
ACTION_TOKEN_INVALID: '操作凭证已失效,请重新操作',
|
||||
SCHEMA_UNSUPPORTED: '当前版本过旧,请刷新页面后重试',
|
||||
IDEMPOTENCY_IN_PROGRESS: '上一次操作正在处理中,请稍后再试',
|
||||
INTERNAL_ERROR: '服务器繁忙,请稍后重试',
|
||||
UNSUPPORTED_MEDIA_TYPE: '不支持的文件类型',
|
||||
CHALLENGE_EXPIRED: '验证码已过期,请重新获取',
|
||||
SMS_DELIVERY_FAILED: '短信发送失败,请稍后重试',
|
||||
PASSWORD_POLICY_VIOLATION: '密码不符合安全要求',
|
||||
VERIFICATION_RATE_LIMITED: (details) =>
|
||||
isRetryAfter(details) ? `操作过于频繁,请 ${details.retry_after_seconds} 秒后重试` : '操作过于频繁,请稍后再试',
|
||||
ALREADY_GROUP_MEMBER: '对方已经是群成员',
|
||||
DESCRIPTION_REQUIRED: '请填写简介内容',
|
||||
DESCRIPTION_TOO_LONG: '简介内容过长',
|
||||
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
|
||||
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
|
||||
INVALID_EMAIL: '邮箱格式不正确',
|
||||
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
|
||||
TOO_MANY_FILES: '文件数量超出限制',
|
||||
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
|
||||
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
|
||||
GROUP_MUTED: '你已被禁言,无法发言',
|
||||
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
|
||||
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
|
||||
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
|
||||
FILE_TOO_LARGE: '文件大小超出限制',
|
||||
FILE_FORBIDDEN: '没有权限访问此文件',
|
||||
FILE_UNAVAILABLE: '文件暂不可用',
|
||||
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
|
||||
REALNAME_REQUIRED: '需要先完成实名认证',
|
||||
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
|
||||
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
|
||||
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
|
||||
// Shared by two unrelated verify endpoints (independent-password PIN check and plain SMS
|
||||
// verification-code verify) -- keep generic enough to be accurate for both.
|
||||
CODE_INVALID: '验证码或独立密码不正确',
|
||||
TOO_MANY_ATTEMPTS: (details) =>
|
||||
isRetryAfter(details) ? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试` : '连续输错次数过多,请稍后重试',
|
||||
};
|
||||
|
||||
function isRetryAfter(details: unknown): details is { retry_after_seconds: number } {
|
||||
return !!details && typeof details === 'object' && 'retry_after_seconds' in details;
|
||||
}
|
||||
|
||||
export function translateReason(reason: string | undefined, details?: unknown): string | null {
|
||||
if (!reason) return null;
|
||||
const entry = REASON_MESSAGES[reason];
|
||||
return (typeof entry === 'function' ? entry(details) : entry) || null;
|
||||
}
|
||||
|
||||
function requestId(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `web_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function apiBase(): string {
|
||||
const endpoints = useSessionStore.getState().tenant?.endpoints;
|
||||
return (endpoints?.api || DEFAULT_API_BASE).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function fileBase(): string {
|
||||
const endpoints = useSessionStore.getState().tenant?.endpoints;
|
||||
return (endpoints?.file || DEFAULT_FILE_BASE).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function currentTenantCode(): string {
|
||||
return useSessionStore.getState().tenant?.code || 'default';
|
||||
}
|
||||
|
||||
/** Set by tinode-web.jsx-equivalent login/reconnect code; tab-lifetime, independent of "remember me". */
|
||||
function authToken(): string {
|
||||
return sessionStorage.getItem('im-session-token') || '';
|
||||
}
|
||||
|
||||
function normalizeResponse(payload: { code?: number; data?: unknown }): unknown {
|
||||
return payload && payload.code === 0 && payload.data !== undefined ? payload.data : payload;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
auth?: boolean;
|
||||
tenantCodeOverride?: string;
|
||||
}
|
||||
|
||||
/** Core request helper. `auth: false` skips the Authorization header (pre-login calls). */
|
||||
export async function imRequest<T = unknown>(
|
||||
path: string,
|
||||
{ method = 'GET', body, headers, auth = true, tenantCodeOverride }: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const finalHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-MeetingIM-APIKey': API_KEY,
|
||||
'X-IM-Schema': '1',
|
||||
'X-Tenant-Code': tenantCodeOverride || currentTenantCode(),
|
||||
'X-Request-ID': requestId(),
|
||||
...headers,
|
||||
};
|
||||
if (auth) {
|
||||
const token = authToken();
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const response = await fetch(apiBase() + path, {
|
||||
method,
|
||||
headers: finalHeaders,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload: { code?: number; data?: unknown; reason?: string; message?: string; details?: unknown } = {};
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
throw new ImApiError(
|
||||
response.ok ? '接口返回非 JSON 数据' : response.statusText || '接口请求失败',
|
||||
'NON_JSON_RESPONSE',
|
||||
response.status,
|
||||
text.slice(0, 160),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!response.ok || (payload?.code && payload.code !== 0)) {
|
||||
const known = translateReason(payload.reason, payload.details);
|
||||
throw new ImApiError(
|
||||
known || payload.message || payload.reason || response.statusText || '请求失败',
|
||||
payload.reason,
|
||||
response.status,
|
||||
payload.details,
|
||||
);
|
||||
}
|
||||
return normalizeResponse(payload) as T;
|
||||
}
|
||||
|
||||
const idem = () => ({ 'Idempotency-Key': requestId() });
|
||||
|
||||
export const imGet = <T = unknown>(path: string, opts?: RequestOptions) => imRequest<T>(path, opts);
|
||||
export const imPost = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
||||
imRequest<T>(path, { method: 'POST', body, headers: idem(), ...opts });
|
||||
export const imPatch = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
||||
imRequest<T>(path, { method: 'PATCH', body, headers: idem(), ...opts });
|
||||
export const imPut = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
||||
imRequest<T>(path, { method: 'PUT', body, headers: idem(), ...opts });
|
||||
export const imDelete = <T = unknown>(path: string, opts: RequestOptions = {}) =>
|
||||
imRequest<T>(path, { method: 'DELETE', headers: idem(), ...opts });
|
||||
|
||||
/**
|
||||
* List endpoints here cap out at 20-50 items per page and it's a real, previously-hit bug class to
|
||||
* only ever fetch page 1 (e.g. "am I this group's owner/admin" going undetected because the real
|
||||
* answer was on page 2). Empty/repeated next_cursor means done.
|
||||
*/
|
||||
export async function fetchAllPages<T>(
|
||||
fetchPage: (cursor: string) => Promise<{ items?: T[]; next_cursor?: string }>,
|
||||
startCursor = '',
|
||||
): Promise<T[]> {
|
||||
let cursor = startCursor;
|
||||
let items: T[] = [];
|
||||
for (;;) {
|
||||
const page = await fetchPage(cursor);
|
||||
items = items.concat(page?.items || []);
|
||||
const nextCursor = page?.next_cursor || '';
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { imRequest } from './client';
|
||||
|
||||
/**
|
||||
* Shape verified against a real `POST /v1/im/tenants/resolve` response (not assumed from memory/
|
||||
* docs) -- code/name are nested under `tenant`, not top-level, and the endpoints block includes a
|
||||
* real resolved `websocket` URL alongside `api`/`file`.
|
||||
*/
|
||||
export interface ResolveTenantResponse {
|
||||
tenant: { code: string; name: string; tenant_desc?: string };
|
||||
endpoints: { api: string; file: string; websocket: string };
|
||||
auth_methods?: string[];
|
||||
password_policy?: { min_length?: number; max_length?: number };
|
||||
}
|
||||
|
||||
export async function resolveTenant(tenantCode: string): Promise<ResolveTenantResponse> {
|
||||
const res = await imRequest<ResolveTenantResponse>('/tenants/resolve', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
tenantCodeOverride: tenantCode,
|
||||
body: { tenant_code: tenantCode, platform: 'web', app_version: __APP_VERSION__ },
|
||||
});
|
||||
// The real backend always answers with absolute URLs (https://api.118im.com/...). In prod that's
|
||||
// same-origin with where this app is actually deployed, so it's harmless there -- but in local
|
||||
// dev those absolute URLs would bypass vite.config.ts's same-origin proxy and hit a real CORS
|
||||
// wall. Dev-only: rewrite api/file back to the relative paths the proxy is listening on. The
|
||||
// `websocket` URL is deliberately left absolute even in dev -- WS isn't subject to fetch()'s
|
||||
// same-origin policy, so the Tinode connection always talks to the real host directly.
|
||||
if (import.meta.env.DEV) {
|
||||
return {
|
||||
...res,
|
||||
endpoints: {
|
||||
...res.endpoints,
|
||||
api: res.endpoints.api.replace(/^https?:\/\/[^/]+/, ''),
|
||||
file: res.endpoints.file.replace(/^https?:\/\/[^/]+/, ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Singleton Tinode connection + a small pub-sub layer wrapping it for React.
|
||||
//
|
||||
// The SDK's onConnect/onDisconnect/onLogin etc. are plain single-slot properties (`tinode.onConnect
|
||||
// = fn`), not a real multi-listener event emitter -- assigning one overwrites any previous
|
||||
// assignment. If more than one React component tried to register its own callback directly (e.g.
|
||||
// each calling useSyncExternalStore with its own subscribe function touching these slots), they'd
|
||||
// silently stomp on each other. This module claims all the SDK's callback slots exactly once and
|
||||
// fans them out to any number of subscribers via its own Set, which is what hooks.ts's
|
||||
// useSyncExternalStore-based hooks actually subscribe to.
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
import { API_KEY, APP_NAME, LOGGING_ENABLED } from '@/lib/config';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'authenticated';
|
||||
|
||||
let tinode: Tinode | null = null;
|
||||
let status: ConnectionStatus = 'disconnected';
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function setStatus(next: ConnectionStatus) {
|
||||
if (status === next) return;
|
||||
status = next;
|
||||
listeners.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
/**
|
||||
* The server extends the stock {hi} packet with a `tenant` field (server/datamodel.go) that the
|
||||
* vendored JS SDK has no API for. `#initPacket`/the connection's packet builder is private, so the
|
||||
* only usable hook is wrapping the connection's raw sendText and injecting the field into the
|
||||
* already-serialized JSON. Ported as-is from the old app's tnSetup/installTenantHiPatch -- this is
|
||||
* a real backend protocol extension, not an old-codebase-specific workaround.
|
||||
*/
|
||||
function installTenantHiPatch(t: Tinode) {
|
||||
const conn = t._connection;
|
||||
if (!conn || conn.__tenantPatched) return;
|
||||
const original = conn.sendText.bind(conn);
|
||||
conn.sendText = (msg: unknown) => {
|
||||
if (typeof msg === 'string' && msg.indexOf('"hi":') >= 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
if (parsed.hi) {
|
||||
const tenant = useSessionStore.getState().tenant;
|
||||
if (tenant?.code) {
|
||||
parsed.hi.tenant = tenant.code;
|
||||
msg = JSON.stringify(parsed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON -- send as is.
|
||||
}
|
||||
}
|
||||
return original(msg as string);
|
||||
};
|
||||
conn.__tenantPatched = true;
|
||||
}
|
||||
|
||||
function createTinode(): Tinode {
|
||||
const tenant = useSessionStore.getState().tenant;
|
||||
const host = tenant?.host || 'web.118im.com';
|
||||
const secure = tenant?.secure ?? true;
|
||||
const t = new Tinode({ appName: APP_NAME, host, apiKey: API_KEY, transport: 'ws', secure, persist: false });
|
||||
t.setHumanLanguage('zh');
|
||||
t.enableLogging(LOGGING_ENABLED, true);
|
||||
installTenantHiPatch(t);
|
||||
|
||||
t.onConnect = () => setStatus('connected');
|
||||
t.onDisconnect = () => setStatus('disconnected');
|
||||
t.onLogin = (code: number) => {
|
||||
if (code === 200) setStatus('authenticated');
|
||||
};
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Lazily creates the singleton connection. Must only be called once a tenant is resolved. */
|
||||
export function getTinode(): Tinode {
|
||||
if (!tinode) {
|
||||
tinode = createTinode();
|
||||
}
|
||||
return tinode;
|
||||
}
|
||||
|
||||
/** Tears down the singleton so the next getTinode() rebuilds against a freshly-resolved tenant. */
|
||||
export function resetTinode() {
|
||||
if (tinode?.isConnected()) {
|
||||
tinode.disconnect();
|
||||
}
|
||||
tinode = null;
|
||||
setStatus('disconnected');
|
||||
}
|
||||
|
||||
export async function connect(): Promise<void> {
|
||||
const t = getTinode();
|
||||
if (t.isConnected()) return;
|
||||
setStatus('connecting');
|
||||
await t.connect();
|
||||
}
|
||||
|
||||
export function getConnectionStatus(): ConnectionStatus {
|
||||
return status;
|
||||
}
|
||||
|
||||
export function subscribeConnectionStatus(callback: () => void): () => void {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import { getConnectionStatus, getTinode, subscribeConnectionStatus } from './connection';
|
||||
|
||||
/** The current WS connection status, reactive via useSyncExternalStore. */
|
||||
export function useConnectionStatus() {
|
||||
return useSyncExternalStore(subscribeConnectionStatus, getConnectionStatus);
|
||||
}
|
||||
|
||||
/** The singleton Tinode client instance (stable reference, not itself reactive). */
|
||||
export function useTinode() {
|
||||
return getTinode();
|
||||
}
|
||||
Vendored
+7
-2
@@ -19,6 +19,10 @@ declare module 'tinode-sdk' {
|
||||
|
||||
export type AuthLevel = 'anon' | 'auth' | 'root';
|
||||
|
||||
export interface LoginCtrl {
|
||||
ctrl: { code: number; text?: string; params?: Record<string, unknown> };
|
||||
}
|
||||
|
||||
export class AccessMode {
|
||||
mode: string;
|
||||
given: string;
|
||||
@@ -101,8 +105,9 @@ declare module 'tinode-sdk' {
|
||||
isAuthenticated(): boolean;
|
||||
getAuthenticatedUserID(): string | undefined;
|
||||
|
||||
login(scheme: string, secret: string, cred?: unknown): Promise<{ ctrl: { code: number; params?: Record<string, unknown> } }>;
|
||||
loginToken(token: string, cred?: unknown): Promise<{ ctrl: { code: number } }>;
|
||||
login(scheme: string, secret: string, cred?: unknown): Promise<LoginCtrl>;
|
||||
loginBasic(username: string, password: string, cred?: unknown): Promise<LoginCtrl>;
|
||||
loginToken(token: string, cred?: unknown): Promise<LoginCtrl>;
|
||||
getAuthToken(): { token: string; expires: Date } | null;
|
||||
|
||||
getMeTopic(): MeTopic;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface TenantContext {
|
||||
code: string;
|
||||
name: string;
|
||||
endpoints: { api: string; file: string };
|
||||
/** Real Tinode WS host + scheme, parsed from tenants/resolve's `endpoints.websocket`. */
|
||||
host: string;
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
tenant: TenantContext | null;
|
||||
myUserId: string | null;
|
||||
setTenant: (tenant: TenantContext) => void;
|
||||
clearTenant: () => void;
|
||||
setMyUserId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant context persists across browser restarts (localStorage) the same way the old app kept
|
||||
* it, since a returning user's auto-login still needs it to build the {hi} packet and every REST
|
||||
* call's X-Tenant-Code header. `myUserId` is *not* persisted here -- it's re-derived from the
|
||||
* Tinode connection's own auth state on reconnect, not something to trust stale from a previous
|
||||
* session.
|
||||
*/
|
||||
export const useSessionStore = create<SessionState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
tenant: null,
|
||||
myUserId: null,
|
||||
setTenant: (tenant) => set({ tenant }),
|
||||
clearTenant: () => set({ tenant: null }),
|
||||
setMyUserId: (id) => set({ myUserId: id }),
|
||||
}),
|
||||
{
|
||||
name: 'meetingim-session',
|
||||
partialize: (state) => ({ tenant: state.tenant }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** Parses `wss://host:port/v0/channels` (or `ws://...`) into Tinode's own {host, secure} shape. */
|
||||
export function parseWebsocketEndpoint(url: string): { host: string; secure: boolean } {
|
||||
const parsed = new URL(url);
|
||||
return { host: parsed.host, secure: parsed.protocol === 'wss:' };
|
||||
}
|
||||
@@ -19,5 +19,16 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 8094,
|
||||
// Same-origin dev proxy for the REST layer only -- avoids CORS entirely (the browser only ever
|
||||
// sees http://localhost:8094) so a resolved tenant's *relative* endpoints ("/v1/im", "/v0/file")
|
||||
// work unmodified in local dev, same idea as the old app's hand-rolled dev-proxy-server.js, just
|
||||
// using Vite's built-in proxy instead of a bespoke Node server. The Tinode WS connection itself
|
||||
// does NOT need this: WebSocket isn't subject to the fetch() same-origin policy, so it connects
|
||||
// straight to the tenant's real host regardless (confirmed working this way throughout the old
|
||||
// app's development too).
|
||||
proxy: {
|
||||
'/v1/im': { target: 'https://api.118im.com', changeOrigin: true, secure: true },
|
||||
'/v0/file': { target: 'https://api.118im.com', changeOrigin: true, secure: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user