npm package: @goplus123/core-api
Description: `core-api` 是一个以 `initSDK` 为装配点的单例 SDK，内部统一封装了三种传输层（HTTP / WS / gRPC），并提供：
Latest version: 1.1.8

# @goplus123/core-api

`core-api` 是一个以 `initSDK` 为装配点的单例 SDK，内部统一封装了三种传输层（HTTP / WS / gRPC），并提供：

- spec 驱动的统一调用入口：`requestApi`
- 类型安全的 service client：`sdk.cms.* / sdk.admin.* / sdk.platform.*`
- WS 推送订阅与本地事件分发：`notify(type)`
- 统一错误归一化与中间件：`apiError.use(...)`

入口文件：[`src/index.ts`](./src/index.ts)

---

## 目录

- [快速开始](#快速开始)
- [调用链路概览](#调用链路概览)
- [Transport 选择规则](#transport-选择规则)
- [WS 推送与事件](#ws-推送与事件)
- [错误处理](#错误处理)
- [响应适配器（Adapters）](#响应适配器adapters)
- [API 参考（对外导出）](#api-参考对外导出)
- [spec 驱动的 Service 列表](#spec-驱动的-service-列表)
- [开发指南：如何补充 spec 路由](#开发指南如何补充-spec-路由)

## 设计方案与思路

### 这是什么

`core-api` 的定位是“前端/Node 侧统一 API SDK 装配点（bootstrapper）”：

- 对外只暴露少量稳定入口：`initSDK / requestApi / notify / apiError / fetchGuid`
- 对内把 HTTP / WS / gRPC 三套通信能力收敛到一个统一调用模型（EndpointSpec + UnifiedApiClient）
- 让业务侧用“service + functionName”或“类型安全的 service client”去调用，而不是直接耦合具体传输协议

### 为什么要这样设计（问题拆解）

这个 SDK 解决的核心矛盾是：同一套业务接口在不同环境、不同阶段可能走不同 transport，同时又希望具备统一的鉴权/日志/错误处理/调试能力。

设计上做了三层解耦：

- “接口长什么样”（spec 层：EndpointSpec/EndpointGroup，见 [`src/spec/endpoint.ts`](./src/spec/endpoint.ts)）
- “怎么路由到哪个 transport”（routing 层：registry 构建 + pickEndpoint + fallback，见 [`src/routing/router.ts`](./src/routing/router.ts)）
- “具体怎么发请求”（client 层：HttpClient/WsClient/GrpcClient + UnifiedApiClient，见 [`src/client/*`](./src/client/)）

这样做带来的直接收益：

- 业务调用方式稳定：接口迁移 transport（例如 ws → grpc）时，调用方不需要全量改造
- 横切能力可复用：鉴权、日志、错误归一化、灰度、重试等可以在统一链路上实现一次
- 便于渐进式改造：spec 驱动与 legacy modules 并存，允许逐步把旧调用迁移进 spec

### 关键设计原则（对后续重构/AI 重设计很重要）

- 单一装配点：只允许通过 `initSDK()` 构建/覆盖全局状态，避免“到处 new client”导致的状态分裂
- 显式路由表：`requestApi` 优先按 spec 路由；迁移期允许显式 `transport: 'ws'` 走 raw WS 兜底
- transport 无关的调用链：上层只关心 EndpointSpec 与请求参数；transport 细节由 UnifiedApiClient 分发
- 横切能力可插拔：拦截器链（interceptors）是统一入口，避免散落在各 transport 内部
- 向后兼容优先：spec 未命中时允许落到 legacy serviceInstances 或 raw WS 兜底（便于迁移期稳定）

---

## 架构分层与职责

### 1) SDK 装配层（initSDK）

入口：[`initSDK`](./src/index.ts#L372-L579)

`initSDK` 做的事情可以概括为“装配 + 建联 + 暴露能力”：

- 创建三种 transport client（HTTP/WS/gRPC）：[`RequestApi`](./src/client/requestApi.ts)
- 构建路由 registry（从 `apiMap`）：[`rebuildEndpointRegistry`](./src/routing/router.ts#L45-L84)
- 初始化 UnifiedApiClient，并注入 interceptors：[`UnifiedApiClient`](./src/client/unifiedApiClient.ts#L139-L274)
- 初始化 WS（仅在这里 connect）：避免重复建连与多实例订阅
- 暴露两类 client：
  - spec 驱动的 service clients：`sdk.cms/sdk.admin/sdk.platform`（由 [`createApiServiceClient`](./src/index.ts#L592-L683) 生成）
  - legacy modules：`sdk.auth/sdk.player/...`（见 [`src/modules/*`](./src/modules/)）

### 2) Spec 层（接口描述与路由表）

spec 的职责是提供“可路由的接口清单”，而不是提供具体实现。

- EndpointSpec：一个接口在某一种 transport 下的“可调用描述”
- EndpointGroup：同一业务接口的多 transport 变体（可指定 defaultTransport）
- apiMap：按 service 聚合 endpoints，用于构建 registry：[`src/spec/index.ts`](./src/spec/index.ts)

这层的价值在于：它是静态、可读、可生成的“接口真相来源”（source of truth）。

### 3) Routing 层（requestApi 路由与兜底）

路由入口：[`requestApi`](./src/routing/router.ts#L203-L263)

核心逻辑：

1. 组装 key：`<service>:<functionName>`
2. 查 registry（由 `apiMap` 构建）
3. 根据 `args.transport / specDefaultTransport / initSDK.defaultTransport` 选出 endpoint（[`pickEndpoint`](./src/routing/router.ts#L267-L296)）
4. 交给 `unifiedClient.call(endpoint, requestParam, meta)` 分发到具体 transport
5. 若 spec 未命中：
   - 尝试 legacy `serviceInstances[service][functionName]`
   - 再兜底到 raw WS：`rpcClient.call(functionName, service, payload, wsOptions)`

### 4) Client 层（具体 transport + 统一分发）

- HttpClient：基于 `fetch`，提供 baseURL 拼接、timeout、header/token 注入、错误结构化：[`src/client/httpClient.ts`](./src/client/httpClient.ts)
- WsClient：管理连接/心跳/重连/请求响应匹配/notify 分发：[`src/client/wsClient.ts`](./src/client/wsClient.ts)
- WsRpcClient：在 WsClient 基础上做“业务 envelope 解包 + 错误归一化”：[`src/client/wsRpcClient.ts`](./src/client/wsRpcClient.ts)
- GrpcClient：基于 ConnectRPC 的 web transport，统一注入 headers/token：[`src/client/grpcClient.ts`](./src/client/grpcClient.ts)
- UnifiedApiClient：把 EndpointSpec + requestParam 统一分发到 http/ws/grpc，并在入口统一承接 interceptors：[`src/client/unifiedApiClient.ts`](./src/client/unifiedApiClient.ts)

---

## 横切能力（鉴权 / 日志 / 错误）

### 拦截器模型（Interceptors）

定义与组合见：[`src/client/interceptors.ts`](./src/client/interceptors.ts)

- `ApiCallContext` 是拦截器的“单次调用上下文”，包含：endpoint / request / headers / meta
- `createAuthInterceptor`：把 `auth.getToken()` 注入为 `Authorization: Bearer <token>`
- `createLogInterceptor`：围绕一次 endpoint 调用输出 request/response/biz_error/transport_error

拦截器的设计意图是把“与业务接口无关的能力”统一实现一次，三种 transport 复用同一条链路。

### 内置拦截器如何启用

SDK 默认只开放“配置式启用”内置拦截器（业务侧目前不需要也不支持直接注入自定义 `ApiInterceptor`）。

```ts
import { initSDK } from '@goplus123/core-api'

initSDK({
  http: { baseUrl: 'https://api.example.com' },
  grpc: { baseUrl: 'https://grpc.example.com' },
  ws: { url: 'wss://ws.example.com' },
  headerConfig: {
    version: '1',
    platformId: 'web',
    isReload: false,
    deviceType: 'web',
    childPlatformId: 'web',
  },
  auth: {
    getToken: () => localStorage.getItem('token'),
  },
  logger: console,
})
```

启用效果：

- HTTP/gRPC：通过拦截器把 token 注入到请求头
- WS：token 由 `WsClient` 在连接 URL/query 或消息体中携带（受浏览器限制无法设置自定义 header）

### token/header 注入策略

`initSDK({ auth })` 提供的 `getToken/getHeaders` 会被注入到：

- HTTP：自动补 `Authorization` 与公共头（调用方显式传入同名 header 时不覆盖）
- gRPC：统一加 `Authorization: Bearer ...`，并合并 callOptions.headers
- WS：浏览器限制无法加自定义 header，因此在 WS 层通过 URL/query 或消息体（headerConfig）携带

对应实现入口：

- HTTP token/header：[`attachToken`](./src/client/httpClient.ts#L107-L134)
- gRPC token/header：[`createAuthInterceptor`](./src/client/grpcClient.ts#L203-L229)
- WS headerConfig：[`WsClient`](./src/client/wsClient.ts#L51-L60)

### 错误归一化（ApiError）

统一错误类型：[`ApiError`](./src/client/unifiedApiClient.ts#L19-L31)

归一化目标：

- 让上层（UI/日志/监控）用一致结构处理错误，不必分辨来自 HTTP/WS/gRPC
- 把错误分为三类：`biz / transport / unknown`，用于更合理的告警与重试策略

### 业务侧案例：统一判断 token 失效（401/403）

业务侧推荐用 `apiError.use(...)` 或 `initSDK({ onApiError })` 统一处理鉴权失效，而不是在每个接口单独 try/catch。

```ts
import { apiError } from '@goplus123/core-api'

apiError.use((error, _ctx, next) => {
  const isHttpAuthFailed =
    error.transport === 'http' && (error.status === 401 || error.status === 403)

  const isGrpcAuthFailed =
    error.transport === 'grpc' &&
    (error.code === 7 || error.code === 16)

  const isWsAuthFailed =
    error.transport === 'ws' && (error.status === 401 || error.status === 403 || error.code === 403)

  if (isHttpAuthFailed || isGrpcAuthFailed || isWsAuthFailed) {
    // 例如：清 token、跳转登录、或触发 refresh token 流程
  }

  return next()
})
```

---

## 依赖与技术方案清单

以下清单以 `@goplus123/core-api` 的 `package.json` 为准：[`package.json`](./package.json)

### 运行时依赖（dependencies）

| 依赖包 | 用途 | 在本仓库的落点 |
|---|---|---|
| `@connectrpc/connect` | ConnectRPC 核心：client、interceptor、CallOptions 等类型 | [`src/client/grpcClient.ts`](./src/client/grpcClient.ts), [`src/index.ts`](./src/index.ts) |
| `@connectrpc/connect-web` | 浏览器侧 transport（Connect/GrpcWeb），通过 fetch 发起 gRPC-web 调用 | [`src/client/grpcClient.ts`](./src/client/grpcClient.ts) |
| `@bufbuild/protobuf` | protobuf message 创建、schema/descriptor、生成代码运行时 | [`src/client/unifiedApiClient.ts`](./src/client/unifiedApiClient.ts), `src/grpcWeb/**` |
| `axios` | 预留/历史依赖：当前 `src/` 未直接使用（HTTP 目前基于 fetch 实现） | 目前无引用（可评估是否移除） |

### 开发/构建依赖（devDependencies）

| 依赖包 | 用途 |
|---|---|
| `tsup` | 打包与产物格式（esm/cjs）+ dts 生成 |
| `typescript` | 类型检查与 dts 生成基础 |

### 内建浏览器能力（不需要额外依赖）

- `fetch`：HTTP transport 的基础能力（并提供超时 AbortController 封装）
- `WebSocket`：WS transport 的基础能力（心跳/重连/订阅在 WsClient 内实现）
- `AbortController`：HTTP 超时/取消

---

## 项目目录结构

```txt
packages/api
  src/
    index.ts              SDK 入口（initSDK / requestApi / notify / apiError）
    client/               HTTP/WS/gRPC client + unifiedClient + interceptors
    routing/router.ts     spec registry 构建 + requestApi 路由与兜底逻辑
    spec/                 spec 驱动路由表（apiMap / endpoints 定义）
    modules/              legacy 模块式封装（主要用于部分 gRPC 服务兼容）
    grpcWeb/              生成代码（proto → ts），不要手改
    utils/                小工具（如 fetchGuid）
```

建议的扩展方式：

- 新增/调整业务接口路由：改 `src/spec/*`，由 `apiMap` 生效
- 调整调用链行为（鉴权/日志/错误归一化）：改 `src/client/*` 与 `src/routing/router.ts`
- 兼容历史“模块式调用”：保留 `src/modules/*`（目前用于 auth/packet/... 等）

## 调用链路概览

从“推荐的 service client”到具体 transport，大致会走：

1. `sdk.<service>.<method>(payload, options?)`
2. `requestApi({ service, functionName, requestParam, wsOptions/callOptions })`
3. 路由选择与兜底：[`src/routing/router.ts`](./src/routing/router.ts)
   - 先查 spec registry（由 `rebuildEndpointRegistry()` 从 `apiMap` 构建）
   - 命中后交给 `UnifiedApiClient` 分发到 `grpc/ws/http`
   - 未命中则尝试 legacy `serviceInstances`
   - 再未命中则兜底走 raw WS（需要已初始化 `ws`）

## 常见开发场景

- 新增 gRPC endpoint：先确认 `src/grpcWeb/grpc/**` 有对应 service/schema，再在 `src/spec/<service>.ts` 增加 `defineEndpoint`
- 给同一方法加多协议：使用 `defineEndpointGroup`，同时提供 `grpc/ws/http` 变体
- 改默认 transport 或自动选择策略：调整 [`pickEndpoint`](./src/routing/router.ts#L267-L296)
- 新增全局鉴权/日志：通过 `initSDK({ auth, logger })`，或改 `src/client/interceptors.ts`

---

## 安装与导入

```ts
import { initSDK, requestApi, notify, apiError, fetchGuid } from '@goplus123/core-api'
```

---

## 快速开始

### 1) 初始化

`initSDK` 是 SDK 的唯一初始化入口（单例，多次调用会覆盖旧状态）。

```ts
const sdk = initSDK({
  ws: {
    url: 'wss://example.com/websocket',
    protocols: fetchGuid(),
    debug: true,
  },
  grpc: {
    baseUrl: 'https://example.com/cms',
  },
  http: {
    baseUrl: 'https://example.com/api',
  },
  headerConfig: {
    version: '1.0.0',
    platformId: '50',
    isReload: false,
    deviceType: 1,
    deviceId: '',
    childPlatformId: '50',
  },
  defaultTransport: 'auto',
  auth: {
    getToken: () => localStorage.getItem('token'),
    getHeaders: () => ({ 'x-lang': 'zh-CN' }),
  },
  onApiError: (error) => {
    console.error('[onApiError]', error)
  },
  logger: console,
})
```

初始化后得到：

- `sdk.api`：底层 `RequestApi`（包含 `http/ws/grpc` 三个 client）
- `sdk.client`：统一路由分发的 `UnifiedApiClient`
- `sdk.rpc`：对外暴露的 `WsRpcClient`（便于调试 / 订阅）
- `sdk.wsNotify`：底层 `WsNotifyCenter`（直接订阅 WS 推送事件）
- `sdk.cms / sdk.admin / sdk.platform`：类型安全的 service client（由 spec 生成）
- `sdk.apiError / sdk.notify`：全局错误与通知中心（同名导出也可直接使用）

对应实现：[`initSDK`](./src/index.ts#L372-L578)

### gRPC：按服务路由 baseUrl（多服务地址扩展）

SDK 在每次 gRPC 调用前，会根据 `service.typeName` 自动选择本次请求的 `baseUrl`（见 [`pickBaseUrl`](./src/client/grpcClient.ts#L157-L183)）。

优先级（从高到低）：

1. `resolveBaseUrl(serviceTypeName)`
2. `baseUrlByService[serviceTypeName]`
3. `baseUrlByService[namespace]`（`namespace = serviceTypeName.split('.')[0]`）
4. `autoBaseUrlByNamespace`（`baseUrl + '/{namespace.toLowerCase()}'`）
5. `baseUrl`（兜底）

注意：`baseUrlByService` 的 key 是严格区分大小写的

- key 要和 `service.typeName` / `namespace` 完全一致（例如 CMS 的 `namespace` 是 `CMS`，而不是 `cms`）
- 如果 key 没命中且开启了 `autoBaseUrlByNamespace: true`，会走兜底的自动拼路径（你看到的请求地址通常会变成 `.../cms/...`）
- value 建议使用“纯 URL 字符串”，不要额外带反引号/空格（例如 `https://fpms-nt.platform88.me/cmsx`）

示例 1：自动按 namespace 拼路径（最省事，要求网关按 `/cms`、`/recommend` 这种路径分 service）

```ts
import { initSDK } from '@goplus123/core-api'

initSDK({
  grpc: {
    baseUrl: 'https://fpms-nt.platform88.me',
    autoBaseUrlByNamespace: true,
  },
  headerConfig: {
    version: '1.0.0',
    platformId: '50',
    isReload: false,
    deviceType: 1,
    deviceId: '',
    childPlatformId: '50',
  },
} as any)
```

示例 2：用 `baseUrlByService` 做静态映射（真实路由前缀与 typeName 不一致、或少量服务走不同域名/路径）

```ts
import { initSDK } from '@goplus123/core-api'

initSDK({
  grpc: {
    baseUrl: 'https://fpms-nt.platform88.me',
    baseUrlByService: {
      recommend: 'https://fpms-nt.platform88.me/recommendation',
      CMS: 'https://fpms-nt.platform88.me/cmsx',
      'CMS.FrontendService': 'https://fpms-nt.platform88.me/cmsx',
      'player.FrontendPlayerService': 'https://fpms-player.platform88.me/player',
    },
  },
  headerConfig: {
    version: '1.0.0',
    platformId: '50',
    isReload: false,
    deviceType: 1,
    deviceId: '',
    childPlatformId: '50',
  },
} as any)
```

示例 3：用 `resolveBaseUrl` 做动态解析（最高优先级；适合灰度/环境/复杂规则）

```ts
import { initSDK } from '@goplus123/core-api'

initSDK({
  grpc: {
    baseUrl: 'https://fpms-nt.platform88.me',
    resolveBaseUrl: (serviceTypeName) => {
      const ns = serviceTypeName.split('.')[0]
      const nsLower = ns.toLowerCase()
      if (nsLower === 'recommend') return 'https://fpms-nt.platform88.me/recommendation'
      if (nsLower === 'player') return 'https://fpms-player.platform88.me/player'
      return `https://fpms-nt.platform88.me/${nsLower}`
    },
  },
  headerConfig: {
    version: '1.0.0',
    platformId: '50',
    isReload: false,
    deviceType: 1,
    deviceId: '',
    childPlatformId: '50',
  },
} as any)
```

### gRPC：打印请求/响应数据（logPayload）

如果需要在项目中把 gRPC 请求/响应的数据打印出来，可以在 `grpc` 配置里开启 `logPayload`。

它会把 payload 序列化成字符串并写入你传入的 `logger.debug`（例如 `console.debug`），字段名为 `payload`。

```ts
import { initSDK } from '@goplus123/core-api'

initSDK({
  grpc: {
    baseUrl: 'https://example.com/cms',
    logPayload: true,
  },
  headerConfig: {
    version: '1.0.0',
    platformId: '50',
    isReload: false,
    deviceType: 1,
    deviceId: '',
    childPlatformId: '50',
  },
  logger: console,
} as any)
```

也可以做更细的控制：

```ts
initSDK({
  grpc: {
    baseUrl: 'https://example.com/cms',
    logPayload: {
      request: true,
      response: false,
      maxLength: 20000,
    },
  },
  headerConfig: {
    version: '1.0.0',
    platformId: '50',
    isReload: false,
    deviceType: 1,
    deviceId: '',
    childPlatformId: '50',
  },
  logger: console,
} as any)
```

注意：请避免在生产环境长期开启，尤其是当 payload 可能包含敏感信息（token/手机号等）时。

### React Native：配置 gRPC invoker（Invoke）（解决 RN 真机 gRPC-Web 兼容问题）

当后端仅提供 gRPC-Web，而 React Native 环境下 `@connectrpc/connect-web` 的 `fetch`/二进制响应解析存在兼容性问题时，可以通过 `grpc.invoker` 注入一层“可替换的底层调用器”，在不改动上层调用方式（`sdk.xxx` / `requestApi` / spec）前提下，切换为 RN 可用的 gRPC-Web 实现。

这个 `invoker` 只是一个“可插拔的 unary 调用实现”，不限定必须使用某个第三方库。
内置的 `builtin` invoker 使用的是 gRPC-Web（二进制）协议：`application/grpc-web+proto`。

`grpc.invoker` 支持两种形态（优先级从高到低）：

- `invoker: GrpcInvoker`：使用外部传入的自定义 invoker
- `invoker: 'builtin'`：使用 SDK 内置的 gRPC-Web unary invoker（基于 `fetch` + `toBinary/fromBinary`）

注意：使用 `invoker: 'builtin'` 时，请确保 `grpc.protocol` 为 `'grpc-web'`。

`invoker.unary()` 的入参约定：

- `baseUrl`：本次请求实际使用的 baseUrl（已根据 service 自动路由）
- `service` / `methodName`：本次调用的服务与方法名
- `method`：服务方法描述（如果可获取），用于取 input/output schema 与 rpcName
- `request`：已经构造好的 protobuf message（上层已用 schema create 过）
- `headers`：最终要发出去的 headers（已合并 auth/getHeaders/静态 headers 与 callOptions.headers）
- `callOptions`：透传的调用选项（例如 timeout），按你的底层实现自行使用

示例 0（推荐）：直接启用内置 invoker（不需要在 RN 工程维护一套 invoke 代码）：

```ts
import { initSDK, fetchGuid } from '@goplus123/core-api'

const sdk = initSDK({
  ws: {
    url: 'wss://example.com/websocket',
    protocols: fetchGuid(),
  },
  grpc: {
    baseUrl: 'https://example.com/cms',
    protocol: 'grpc-web',
    invoker: 'builtin',
  },
  http: {
    baseUrl: 'https://example.com/api',
  },
  headerConfig: {
    version: '1.0.0',
    platformId: '50',
    isReload: false,
    deviceType: 1,
    deviceId: '',
    childPlatformId: '50',
  },
  defaultTransport: 'auto',
})
```

示例 A（自定义）：在 RN 工程内用 `fetch` 走 gRPC-Web（二进制），复用本 SDK 的 bufbuild 生成物（不需要重新编译 proto）。这个方案不需要引入 `@improbable-eng/grpc-web-react-native-transport`：

```ts
import { initSDK, fetchGuid } from '@goplus123/core-api'
import { fromBinary, toBinary } from '@bufbuild/protobuf'

function concatBytes(chunks: Uint8Array[]): Uint8Array {
  let len = 0
  for (const c of chunks) len += c.byteLength
  const out = new Uint8Array(len)
  let offset = 0
  for (const c of chunks) {
    out.set(c, offset)
    offset += c.byteLength
  }
  return out
}

function grpcWebEncodeUnary(messageBytes: Uint8Array): Uint8Array {
  const out = new Uint8Array(5 + messageBytes.byteLength)
  out[0] = 0
  const view = new DataView(out.buffer, out.byteOffset, out.byteLength)
  view.setUint32(1, messageBytes.byteLength, false)
  out.set(messageBytes, 5)
  return out
}

function bytesToAscii(bytes: Uint8Array): string {
  let s = ''
  for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!)
  return s
}

function parseGrpcWebTrailers(bytes: Uint8Array): Record<string, string> {
  const text = bytesToAscii(bytes)
  const out: Record<string, string> = {}
  for (const line of text.split('\r\n')) {
    if (!line) continue
    const idx = line.indexOf(':')
    if (idx < 0) continue
    const k = line.slice(0, idx).trim().toLowerCase()
    const v = line.slice(idx + 1).trim()
    out[k] = v
  }
  return out
}

function grpcWebDecodeUnary(body: Uint8Array): { message?: Uint8Array; trailers: Record<string, string> } {
  let offset = 0
  let message: Uint8Array | undefined
  let trailers: Record<string, string> = {}
  while (offset + 5 <= body.byteLength) {
    const flags = body[offset]!
    const length = new DataView(body.buffer, body.byteOffset + offset + 1, 4).getUint32(0, false)
    offset += 5
    if (offset + length > body.byteLength) break
    const frame = body.subarray(offset, offset + length)
    offset += length
    const isTrailer = (flags & 0x80) === 0x80
    if (isTrailer) {
      trailers = { ...trailers, ...parseGrpcWebTrailers(frame) }
    } else if (message == null) {
      message = frame
    }
  }
  return { message, trailers }
}

function joinUrl(baseUrl: string, path: string): string {
  return baseUrl.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '')
}

function capitalizeRpcName(methodName: string): string {
  if (!methodName) return methodName
  return methodName[0]!.toUpperCase() + methodName.slice(1)
}

function resolveServiceMethod(service: any, methodName: string, method?: any): any | undefined {
  if (method) return method

  const expectedLocalName = methodName
  const expectedRpcName = capitalizeRpcName(methodName)

  const direct = service?.method
  if (Array.isArray(direct)) {
    return direct.find((m: any) => {
      const localName = String(m?.localName ?? '')
      const rpcName = String(m?.name ?? '')
      return (
        localName === expectedLocalName ||
        localName === expectedRpcName ||
        rpcName === expectedLocalName ||
        rpcName === expectedRpcName
      )
    })
  }
  if (direct && typeof direct === 'object') {
    return (direct as any)[expectedLocalName] ?? (direct as any)[expectedRpcName]
  }

  const methods = service?.methods
  if (Array.isArray(methods)) {
    return methods.find((m: any) => {
      const localName = String(m?.localName ?? '')
      const rpcName = String(m?.name ?? '')
      return (
        localName === expectedLocalName ||
        localName === expectedRpcName ||
        rpcName === expectedLocalName ||
        rpcName === expectedRpcName
      )
    })
  }
  if (methods && typeof methods === 'object') {
    return (methods as any)[expectedLocalName] ?? (methods as any)[expectedRpcName]
  }

  return undefined
}

function getGrpcWebPath(service: any, methodName: string, method: any): string {
  const typeName = String(service?.typeName ?? '')
  const rpcName = String(method?.name ?? method?.rpc?.name ?? '') || capitalizeRpcName(methodName)
  return `${typeName}/${rpcName}`
}

async function rnGrpcWebUnary(args: {
  baseUrl: string
  service: any
  methodName: string
  method?: any
  request: any
  headers: Record<string, string>
  callOptions?: { timeoutMs?: number }
}): Promise<any> {
  const method = resolveServiceMethod(args.service, args.methodName, args.method)
  if (!method) {
    throw new Error(`gRPC method not found: ${String(args.service?.typeName ?? '')}.${args.methodName}`)
  }

  const url = joinUrl(args.baseUrl, getGrpcWebPath(args.service, args.methodName, method))
  const contentType = 'application/grpc-web+proto'

  if (!method.input || !method.output) {
    throw new Error(`gRPC method schema missing: ${String(args.service?.typeName ?? '')}.${args.methodName}`)
  }
  const requestBytes = toBinary(method.input, args.request)
  const body = grpcWebEncodeUnary(requestBytes)

  const controller = new AbortController()
  const timeoutMs = args.callOptions?.timeoutMs
  const timeoutId = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : undefined
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      ...args.headers,
      'content-type': contentType,
      accept: contentType,
      'x-grpc-web': '1',
    },
    body,
    signal: controller.signal,
  })
  try {
    if (!res.ok) {
      throw new Error(`gRPC fetch failed: ${res.status} ${res.statusText}`)
    }
    const raw = new Uint8Array(await res.arrayBuffer())
    const decoded = grpcWebDecodeUnary(raw)
    const grpcStatus = decoded.trailers['grpc-status'] ?? '0'
    if (grpcStatus !== '0') {
      const msg = decoded.trailers['grpc-message'] ?? 'gRPC request failed'
      throw new Error(`gRPC status=${grpcStatus} message=${msg}`)
    }
    if (!decoded.message) throw new Error('gRPC response message missing')
    return fromBinary(method.output, decoded.message)
  } finally {
    if (timeoutId) clearTimeout(timeoutId)
  }
}

const rnGrpcInvoker = { unary: rnGrpcWebUnary }

const sdk = initSDK({
  ws: {
    url: 'wss://example.com/websocket',
    protocols: fetchGuid(),
  },
  grpc: {
    baseUrl: 'https://example.com/cms',
    protocol: 'grpc-web',
    invoker: rnGrpcInvoker,
  },
  http: {
    baseUrl: 'https://example.com/api',
  },
  headerConfig: {
    version: '1.0.0',
    platformId: '50',
    isReload: false,
    deviceType: 1,
    deviceId: '',
    childPlatformId: '50',
  },
  defaultTransport: 'auto',
})
```

补充说明：

- 自定义 invoker 时，建议优先使用 `args.method`（SDK 解析好的方法描述），避免在 RN 侧重复解析 `service.method/service.methods` 的结构差异。
- 如果你已经在 RN 工程里有基于 `@improbable-eng/grpc-web` / `@improbable-eng/grpc-web-react-native-transport` 的 `rnGrpcWebUnary` 封装，也可以直接把它塞进 `invoker.unary`。但需要确保它能消费本 SDK 生成的 bufbuild message 与 schema（`toBinary/fromBinary`），否则通常会因为“message/descriptor 体系不一致”（improbable 常见搭配是另一套 proto 生成物）而对不上类型与序列化方式。

### 2) 直接调用（推荐）

优先使用 service client（类型提示完整，第二参随 transport 自动变化）：

```ts
await sdk.cms?.getPageResource({
  resourceId: 'HOME_PAGE',
})
```

### 3) 统一调用（更底层、更灵活）

当你想通过 `service + functionName` 动态调用时，使用 `requestApi`：

```ts
await requestApi({
  service: 'cms',
  functionName: 'getPageResource',
  requestParam: { resourceId: 'HOME_PAGE' },
})
```

---

## Transport 选择规则

`requestApi` 的 transport 决策优先级：

1. 调用参数 `args.transport`
2. spec 上的 `defaultTransport`（仅对 `defineEndpointGroup` 生效）
3. `initSDK({ defaultTransport })`
4. `'auto'`（默认）：按当前 SDK 初始化状态优先选择 `grpc → ws → http`

对应逻辑：[`pickEndpoint`](./src/routing/router.ts#L267-L296)

---

## WS 推送与事件

`notify(type)` 返回一个通知通道（channel），用于承接两类事件：

- 服务端 WS 推送：初始化了 `ws` 时，会自动挂载到 `ws.notify`，把推送分发到同名 channel
- 本地事件分发：可用 `emit` 主动触发（不发给服务端），用于调试/桥接

channel 支持的方法：

- `use(middleware)`：注册中间件（可在内部决定是否继续传递）
- `subscribe(listener)`：注册订阅者（等价于一个简单的中间件包装）
- `emit(payload)`：主动触发一次本地分发
- `destroy()`：销毁该 type 的 channel 与所有监听

### 直接订阅 wsNotify

如果你想绕开 `notify(type)` 的 channel/middleware 机制，直接订阅 WS 推送事件，可以使用 `sdk.wsNotify`：

```ts
const sdk = initSDK({
  ws: { url: 'wss://example.com/websocket' },
  headerConfig: {
    version: '1.0.0',
    platformId: '0',
    isReload: false,
    deviceType: 1,
    childPlatformId: '0',
  },
} as any)

const off = sdk.wsNotify?.on<{ gameId: string }>('notifyGameInfo', (payload) => {
  console.log('game info:', payload)
})

// off?.()
```

### 订阅 WS 推送

`notify(type)` 会创建一个 channel，并在初始化了 `ws` 时自动挂载到 `ws.notify`。

```ts
const off = notify<{ gameId: string }>('notifyGameInfo').subscribe((payload) => {
  console.log('game info:', payload)
})

// off()
```

### notify 中间件（use）

`use` 适合做过滤、埋点、转换、桥接等逻辑；签名为 `(payload, ctx, next) => any`，调用 `next()` 才会继续向后传递。

```ts
import { notify } from '@goplus123/core-api'

notify<{ level: 'info' | 'error'; message: string }>('app:log').use((payload, _ctx, next) => {
  if (payload.level !== 'error') return
  console.log('[app:log][error]', payload.message)
  return next()
})
```

### 主动触发 notify（emit）

`notify(type).emit(payload)` 可以主动触发一次本地事件分发（不会发给服务端），常用于调试/本地桥接复用同一套订阅机制。

```ts
import { notify } from '@goplus123/core-api'

const off = notify<{ type: number }>('wsStatus').subscribe((payload) => {
  console.log('wsStatus payload:', payload.type)
})

notify('wsStatus').emit({ type: 2 })

off()
```

### 监听 WS 连接状态

SDK 内置了 `ws:status` 通道（初始化时自动绑定到 WS 事件）：

```ts
notify('ws:status').subscribe((s) => {
  console.log('ws status:', s.state, s.readyState)
})
```

也可以同步读取快照：

```ts
import { getWsSnapshot } from '@goplus123/core-api'

const { isConnected, readyState, status } = getWsSnapshot()
```

---

## 错误处理

### 全局错误中间件

```ts
apiError.use((error, ctx, next) => {
  console.error('[apiError]', ctx.source, error)
  return next()
})
```

- `initSDK({ onApiError })` 会自动注册一层中间件
- 未初始化时调用 `apiError.use/emit` 为 no-op（会输出 warn）

---

## 响应适配器（Adapters）

适配器用于把服务端“业务 envelope”映射为 SDK 的成功/失败判定与返回值结构，主要解决：

- 同一套接口在不同 transport 下返回结构不一致（字段名不同、`data` 多层嵌套等）
- 业务错误通过 `code/message` 表达，但 transport 层仍然正常返回
- 需要从响应中提取 `status/code/message/requestId` 进入统一错误体系

SDK 支持两类适配器：

- WS：作用于 [`WsRpcClient`](./src/client/wsRpcClient.ts)，按 `{ service, functionName }` 匹配
- gRPC：作用于 [`UnifiedApiClient`](./src/client/unifiedApiClient.ts) 的 gRPC 调用，按 `{ endpointId, serviceTypeName, methodName }` 匹配

### 注册入口（对外导出）

入口：[`adapters`](./src/index.ts#L430-L485)

```ts
import { adapters } from '@goplus123/core-api'

adapters.ws.setDefault(undefined)
adapters.ws.add({ match: { service: /.*/, functionName: /.*/ } })
adapters.ws.clear()

adapters.grpc.setDefault(undefined)
adapters.grpc.add({ match: { endpointId: /.*/, serviceTypeName: /.*/, methodName: /.*/ } })
adapters.grpc.clear()
```

- `add()` 返回 `off()`，用于注销本次注册
- 可在 `initSDK()` 前后调用：初始化后会自动挂载到内部 client
- 匹配优先级：先匹配 `add()` 注册的列表，再匹配 `setDefault()`；`add()` 默认 `prepend: true`（后注册的优先匹配）

### WS：match 与字段

match 支持两种形态（任选其一）：

- `{ service?: string | RegExp; functionName?: string | RegExp }`
- `(ctx: { service: string; functionName: string }) => boolean`

可配置字段：

- `isSuccess(res)`：自定义成功判定
- `unwrap(res)`：自定义解包逻辑（返回给业务侧的最终结果）
- `getBizCode/getCode/getStatus/getMessage/getRequestId(res)`：用于 SDK 抽取错误信息与 requestId

默认行为（不配置 adapter 时）：

- 成功判定优先级：`success(boolean|string)` → `status(200)` → `code(0|200)` → 其它情况认为成功
- 默认会把响应里的 `data` 作为返回值（`unwrapData` 默认 `true`，见 [`WsRpcClient`](./src/client/wsRpcClient.ts#L84-L110)）

示例：适配 `{ status, code, message, data }` 且允许 `data` 多层嵌套

```ts
import { adapters } from '@goplus123/core-api'

adapters.ws.setDefault({
  match: { service: /.*/, functionName: /.*/ },
  isSuccess: (res: any) => Number(res?.status) === 200,
  unwrap: (res: any) => res?.data?.data ?? res?.data ?? res,
  getBizCode: (res: any) => (Number.isFinite(Number(res?.code)) ? Number(res?.code) : undefined),
  getStatus: (res: any) => (Number.isFinite(Number(res?.status)) ? Number(res?.status) : undefined),
  getMessage: (res: any) =>
    typeof res?.message === 'string' ? res.message : typeof res?.msg === 'string' ? res.msg : undefined,
  getRequestId: (res: any) => res?.requestId ?? res?.reqId ?? res?.data?.requestId,
})
```

示例：只对某个接口覆盖解包（其它接口仍走默认 adapter）

```ts
import { adapters } from '@goplus123/core-api'

adapters.ws.add(
  {
    match: { service: 'platform', functionName: 'getConfig' },
    unwrap: (res: any) => res?.data ?? res,
  },
  { prepend: true },
)
```

### gRPC：match 与字段

match 支持两种形态（任选其一）：

- `{ endpointId?: string | RegExp; serviceTypeName?: string | RegExp; methodName?: string | RegExp }`
- `(ctx: { endpointId: string; serviceTypeName: string; methodName: string }) => boolean`

可配置字段：

- `isSuccess(res)`：自定义成功判定
- `unwrap(res)`：自定义解包逻辑（返回给业务侧的最终结果）
- `getBizCode/getCode/getStatus/getMessage(res)`：用于 SDK 生成业务错误（`GrpcBizError`）时抽取信息

注意：

- gRPC adapter 只对“走 `UnifiedApiClient` 的 gRPC 调用”生效（例如 `requestApi` / `sdk.cms.*` / `sdk.admin.*`）
- 若没有匹配到 adapter，SDK 会直接返回 protobuf message，不做业务失败判定

示例：给所有 gRPC 响应统一按 `{ code, message, data }` 做业务判定与解包

```ts
import { adapters } from '@goplus123/core-api'

adapters.grpc.setDefault({
  match: { endpointId: /.*/, serviceTypeName: /.*/, methodName: /.*/ },
  isSuccess: (res: any) => Number(res?.code) === 0,
  unwrap: (res: any) => res?.data ?? res,
  getBizCode: (res: any) => (Number.isFinite(Number(res?.code)) ? Number(res?.code) : undefined),
  getMessage: (res: any) =>
    typeof res?.message === 'string' ? res.message : typeof res?.msg === 'string' ? res.msg : undefined,
})
```

示例：只对某个 gRPC 方法开启业务 envelope（其它方法仍返回原始 message）

```ts
import { adapters } from '@goplus123/core-api'

adapters.grpc.add({
  match: { serviceTypeName: 'CMS.FrontendService', methodName: 'getPageResource' },
  isSuccess: (res: any) => Number(res?.status) === 200,
  unwrap: (res: any) => res?.data ?? res,
})
```

---

## API 参考（对外导出）

来自：[`src/index.ts`](./src/index.ts)

### initSDK(config)

- 作用：初始化（或重置）SDK 单例，并按配置装配 HTTP/WS/gRPC、路由、拦截器与 service clients
- 入参类型：`RequestApiConfig`（见 [`RequestApiConfig`](./src/client/requestApi.ts#L7-L36)）
- 返回：`{ api, rpc?, client?, wsNotify?, apiError, notify, ...services }`

### destroySDK()

- 作用：释放资源（断开 WS、清空全局状态、重置路由与中心）

### getSDK()

- 作用：获取底层 `RequestApi` 实例
- 注意：未初始化会抛错

### requestApi(args) / callApi(args)

- 作用：统一调用入口（spec 驱动路由；若 spec 未命中，会尝试 legacy serviceInstances，再兜底走 raw WS）
- 别名：
  - `callApi`：`requestApi` 的同名别名
  - `requestApiSpec`：`requestApi` 的同名别名（历史兼容）
- 入参：
  - `service: string`
  - `functionName: string`
  - `transport?: 'http' | 'ws' | 'grpc' | 'auto'`
  - `requestParam?: any`
  - `wsOptions?: WsRequestOptions`
  - `callOptions?: CallOptions`
  - `meta?: Record<string, any>`

### getWsSnapshot()

- 作用：同步获取 WS 连接状态快照（不依赖订阅）
- 返回：`{ readyState?: number; isConnected: boolean; status?: WsStatusPayload }`

### notify(type)

- 作用：获取通知通道（channel）
- 方法：
  - `use(middleware)`：注册中间件；返回 `off()` 用于注销当前中间件
  - `subscribe(listener)`：注册订阅者；返回 `off()` 用于注销当前订阅
  - `emit(payload)`：主动触发一次本地分发（不发给服务端）
  - `destroy()`：销毁该 type 的 channel 与所有监听
- 注意：未初始化时调用 `emit` 为 no-op（会 warn）；`use/subscribe` 可提前注册，初始化后会自动挂载到 WS 推送

### apiError

- `apiError.use(middleware)`：注册错误中间件
- `apiError.emit(error, source?)`：手动注入一个归一化后的 `ApiError`

### 类型导出（常用）

- `WsStatusState` / `WsStatusPayload`：WS 状态类型（配合 `ws:status` 与 `getWsSnapshot`）
- `ApiErrorMiddleware`：`apiError.use(...)` 的中间件签名
- `NotifyMiddleware`：`notify(type).use(...)` 的中间件签名
- `KeyValueStorage`：unikey 存储抽象

### fetchGuid()

- 作用：生成/读取一个稳定的设备唯一标识（unikey），用于跨请求/跨连接的设备识别
- 行为：
  - 读取存储：优先从当前 `KeyValueStorage` 读取 `uKey`
  - 解析兼容：支持三种历史值格式（UUID / 32 位 hex / base64(bytes)）
  - 缓存写入：若不存在或不可解析，会生成新的 UUID 并写回存储
  - 默认输出：返回 UUID（带 `-`），例如 `xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx`
- 默认存储策略（见 [`utils/uniqueKey.ts`](./src/utils/uniqueKey.ts)）：
  - Web 环境：使用 `localStorage`
  - 非 Web 或 `localStorage` 不可用：使用进程内 memory storage 兜底（保证同进程稳定）

### createFetchGuid(storage, options?)

- 作用：基于指定的 `KeyValueStorage` 创建一个 `fetchGuid()` 函数（推荐方式，便于按平台定制）
- options：
  - `key?: string`：存储 key，默认 `uKey`
  - `format?: 'uuid' | 'hex' | 'base64'`：返回格式，默认 `'uuid'`
  - `prefix?: string`：输出前缀（用于区分来源端，例如 `'pc_' / 'h5_'`），默认 `''`

示例：

```ts
import { createFetchGuid, webStorage } from '@goplus123/core-api'

export const fetchGuidPc = createFetchGuid(webStorage, { format: 'uuid', prefix: 'pc_' })
export const fetchGuidH5 = createFetchGuid(webStorage, { format: 'hex', prefix: 'h5_' })
```

### setUniqueKeyStorage(adapter)

- 作用：设置 `fetchGuid()` 的全局存储适配器（用于 App/小程序等非 Web 环境注入 native storage）
- 建议：尽量在 `initSDK()` 之前调用，避免同一进程内出现“先生成、后换存储”的多份 id

示例（伪代码）：

```ts
import type { KeyValueStorage } from '@goplus123/core-api'
import { setUniqueKeyStorage } from '@goplus123/core-api'

const appStorage: KeyValueStorage = {
  get(key) {
    return NativeStorage.get(key)
  },
  set(key, val) {
    NativeStorage.set(key, val)
  },
}

setUniqueKeyStorage(appStorage)
```

### webStorage

- 作用：浏览器端 `KeyValueStorage` 适配器，基于 `window.localStorage`
- 兼容：SSR/Node 下安全返回 `null`；浏览器隐私模式/禁用存储时会吞掉异常（不抛错）

### KeyValueStorage

- 作用：unikey 存储抽象（只要求 `get/set` 两个方法）
- 定义：[`utils/storage.ts`](./src/utils/storage.ts)

---

## spec 驱动的 Service 列表

spec 定义入口：[`spec/index.ts`](./src/spec/index.ts)

说明：

- spec 影响的是 `requestApi` 的路由表（能让 `requestApi({ service, functionName })` 正确选 transport）
- 当前 `initSDK()` 默认只创建了 `sdk.cms / sdk.admin / sdk.platform` 三个“类型安全的 service client”
- 其它 spec service 仍然可以通过 `requestApi` 调用（必要时再扩展 sdk 返回值即可）

按 service 拆分的落点文件：

- admin：[`spec/admin.ts`](./src/spec/admin.ts)
- auth：[`spec/auth.ts`](./src/spec/auth.ts)
- cms：[`spec/cms.ts`](./src/spec/cms.ts)
- packet：[`spec/packet.ts`](./src/spec/packet.ts)
- payment：[`spec/payment.ts`](./src/spec/payment.ts)
- player：[`spec/player.ts`](./src/spec/player.ts)
- platform：[`spec/platform.ts`](./src/spec/platform.ts)
- promotion：[`spec/promotion.ts`](./src/spec/promotion.ts)
- provider：[`spec/provider.ts`](./src/spec/provider.ts)
- recommend：[`spec/recommend.ts`](./src/spec/recommend.ts)
- riskcontrol：[`spec/riskcontrol.ts`](./src/spec/riskcontrol.ts)
- userEngagement：[`spec/userEngagement.ts`](./src/spec/userEngagement.ts)

### cms（gRPC 为主，部分接口可多协议；支持 sdk.cms）

- `getPageResource`
- `getPageGameList`（multi：grpc + ws）
- `getPageGameListV2`（multi：grpc + ws）
- `getJackpotResources`
- `getJackpotConfig`
- `readPopup`
- `readEventPopup`
- `getAmbassadorConfig`
- `getAmbassadorConfigDetail`
- `searchGame`
- `searchGameV2`
- `getSportConfig`
- `getSportTodayMatch`
- `getGameSuggestions`

### admin（gRPC；支持 sdk.admin）

- `signIn`
- `getAdminProfileByJwt`
- `validateAdmin`
- `signUpAdmin`
- `updatePlatformConfig`
- `checkHasPermission`
- `getGameName`
- `getPlayerCredibilityRemarkId`
- `getMarketingDepartmentConfig`
- `createMarketingDepartmentConfig`
- `updateMarketingDepartmentConfig`
- `deleteMarketingDepartmentConfig`

### platform（WS；支持 sdk.platform）

- `getConfig`

### riskcontrol（gRPC）

- `getPlayerDepositLimitStateOverview`
- `updatePlayerDepositLimitSwitch`
- `updatePlayerPeriodDepositLimit`

### userEngagement（gRPC）

- `updatePWASubscriptionStatus`

### provider（gRPC）

- `getRecentPlayedGames`
- `getLoginURL`

### recommend（gRPC）

- `saveSearchHistory`
- `getSearchHistories`
- `deleteSearchHistory`
- `searchGames`

### packet（gRPC）

- `assignPacket`
- `getServerTime`
- `getPacketsInfo`
- `grabFreeSpin`
- `getEventGrabProgress`
- `verifyPlayerPacket`
- `verifyPlayerFreeSpin`
- `playerClaimAllRewardsLite`

### payment（gRPC）

- `getCredit`
- `getBankTypes`
- `getBindBankCards`
- `getAvailableDepositMethods`
- `updatePaymentInfo`
- `verifyUnionBank`
- `getUnionBankTopUp`
- `getPlayerDisbursements`
- `claimPlayerDisbursement`
- `getMud`
- `getPlayerMudTransactions`
- `getMudTransactionHistory`
- `getCreditChangeNotifyMsg`
- `removeCreditChangeNotifyMsg`
- `getPlatformPlayerFreeSpinListing`

### auth / player / promotion

- 这三个 service 的 spec 方法较多，建议直接查看对应 spec 文件的 `export const <service>Endpoints` 键名。

---

## 为接口新增/扩展 transport（spec 写法）

spec 工具位于：[`spec/endpoint.ts`](./src/spec/endpoint.ts)

## 开发指南：如何补充 spec 路由

spec 的职责是给 `requestApi` 提供“路由表”：

- runtime 上它会被加载进 `apiMap`，并在 `initSDK()` 时构建 registry（见 [`rebuildEndpointRegistry`](./src/routing/router.ts#L84-L132)）
- 调用方可以通过 `sdk.<service>.<method>()` 或 `requestApi({ service, functionName })` 走到对应 transport
- 当调用方显式指定 `transport` 时：
  - `grpc/http`：若 spec 命中但没有对应变体，会在路由选择阶段同步抛错
  - `ws`：若 spec 命中但没有对应变体，会兜底走 raw WS（需要已初始化 ws）

### 1) 选择落点文件

- cms 的 spec：[`src/spec/cms.ts`](./src/spec/cms.ts)
- admin 的 spec：[`src/spec/admin.ts`](./src/spec/admin.ts)
- platform（WS）spec：[`src/spec/platform.ts`](./src/spec/platform.ts)
- 聚合与 apiMap：[`src/spec/index.ts`](./src/spec/index.ts)

### 2) 约定：id / service / functionName

- `id` 建议固定为：`<service>.<functionName>`，例如 `cms.getPageGameList`
- `requestApi` 侧入参使用：`service: '<service>'` + `functionName: '<functionName>'`
- WS spec 内的 `ws.service / ws.functionName` 是 WS 协议层的 service/functionName（通常与上面一致，但不是强制）

### 3) 新增一个 endpoint（单协议）

#### gRPC（最常见）

在 `src/spec/cms.ts`（或对应 service 的 spec 文件）中追加：

```ts
import type { MessageInit } from '../client/grpcClient'
import { defineEndpoint } from './endpoint'
import { FrontendService as CmsFrontendService } from '../grpcWeb/grpc/cms/FrontendService_pb'
import type { PageResourceRes } from '../grpcWeb/grpc/cms/GetPageResource_pb'
import { PageResourceReqSchema } from '../grpcWeb/grpc/cms/GetPageResource_pb'

export const cmsEndpoints = {
  // ...
  getPageResource: defineEndpoint<MessageInit<typeof PageResourceReqSchema>, PageResourceRes>({
    id: 'cms.getPageResource',
    transport: 'grpc',
    grpc: {
      service: CmsFrontendService,
      methodName: 'getPageResource',
      requestSchema: PageResourceReqSchema,
    },
  }),
}
```

调用方式：

```ts
await requestApi({
  service: 'cms',
  functionName: 'getPageResource',
  requestParam: { resourceId: 'HOME_PAGE' },
})
```

或（更推荐）通过 service client：

```ts
await sdk.cms?.getPageResource({ resourceId: 'HOME_PAGE' })
```

#### WS

在 `src/spec/platform.ts`（或你新增的 service 对应 spec 文件）追加：

```ts
import { defineEndpoint } from './endpoint'

export const platformEndpoints = {
  getConfig: defineEndpoint<any, any>({
    id: 'platform.getConfig',
    transport: 'ws',
    ws: { service: 'platform', functionName: 'getConfig', timeout: 10000 },
  }),
}
```

调用方式：

```ts
await requestApi({
  service: 'platform',
  functionName: 'getConfig',
  requestParam: { platformId: '50' },
})
```

### 4) 给同一个 endpoint 增加新的 transport 变体（多协议）

当你希望同一业务方法同时支持 `grpc/ws/http` 时，用 `defineEndpointGroup`：

- `defaultTransport`：该 endpoint 在未指定 `transport` 时的默认选择
- `transports`：按 transport 分别提供配置

```ts
import type { MessageInit } from '../client/grpcClient'
import { defineEndpointGroup } from './endpoint'
import { FrontendService as CmsFrontendService } from '../grpcWeb/grpc/cms/FrontendService_pb'
import type { PageGameListRes } from '../grpcWeb/grpc/cms/GetPageGameList_pb'
import { PageGameListReqSchema } from '../grpcWeb/grpc/cms/GetPageGameList_pb'

export const cmsEndpoints = {
  // ...
  getPageGameList: defineEndpointGroup<MessageInit<typeof PageGameListReqSchema>, PageGameListRes>({
    id: 'cms.getPageGameList',
    defaultTransport: 'grpc',
    transports: {
      grpc: { service: CmsFrontendService, methodName: 'getPageGameList', requestSchema: PageGameListReqSchema },
      ws: { service: 'cms', functionName: 'getPageGameList' },
    },
  }),
}
```

调用时可显式选择：

```ts
await requestApi({
  service: 'cms',
  functionName: 'getPageGameList',
  transport: 'ws',
  requestParam: { resourceId: 'GAME_PAGE_ALL_GAMES', pagination: { limit: 10, offset: 0 } },
})
```

注意：增加 WS/http 变体不仅是前端 spec 改动，服务端也必须实现对应 handler，否则会在 transport 层报错（但不会再卡在路由校验阶段）。

### 5) 新增一个 service（扩展 apiMap / 返回的 sdk）

如果要新增 `fooEndpoints` 这样的新 service：

1. 新建 `src/spec/foo.ts` 并导出 `fooEndpoints`
2. 在 `src/spec/index.ts` 里导出 `fooEndpoints`，并把它加入 `apiMap`
3. 在 `src/index.ts` 的 `initSDK` 返回类型里补充 `foo?: ApiServiceClient<'foo'>`
4. 在 `initSDK` 里按 transport 条件创建 `sdk.foo = createApiServiceClient('foo')`

`createApiServiceClient` 的实现位置：[`createApiServiceClient`](./src/index.ts#L592-L683)

### 单协议（defineEndpoint）

```ts
import { defineEndpoint } from './endpoint'

export const demo = defineEndpoint({
  id: 'cms.getPageResource',
  transport: 'grpc',
  grpc: { service: CmsFrontendService, methodName: 'getPageResource', requestSchema: PageResourceReqSchema },
})
```

### 多协议（defineEndpointGroup）

```ts
import { defineEndpointGroup } from './endpoint'

export const demo = defineEndpointGroup({
  id: 'cms.getPageGameList',
  defaultTransport: 'grpc',
  transports: {
    grpc: { service: CmsFrontendService, methodName: 'getPageGameList', requestSchema: PageGameListReqSchema },
    ws: { service: 'cms', functionName: 'getPageGameList' },
  },
})
```

---

## 扩展与改造思路（给后续技术升级/AI 重设计用）

### 推荐的扩展入口（优先级从高到低）

1. **扩接口/补路由**：新增或调整 `src/spec/*`，保持 `id` 与 `service/functionName` 约定一致（最安全、最不影响业务）
2. **扩横切能力**：在 `initSDK` 注入 interceptors（或扩展内置 interceptors），实现鉴权/日志/重试/缓存/灰度/追踪
3. **扩 transport 能力**：增强 HttpClient/WsClient/GrpcClient（例如：统一重试、断路器、requestId 透传、metrics）
4. **迁移 legacy modules → spec**：逐个把 `src/modules/*` 中的接口补到 spec，并在业务侧切到 `requestApi`/service client

### 适合做技术改造的“稳定边界”

如果未来要用 AI 或进行架构升级，建议把以下内容视为“对外契约”，优先保持兼容：

- `initSDK(config)` 的输入语义：装配 transport、注入 auth/logger、建立 WS 连接、构建 registry、暴露 service clients
- `requestApi(args)` 的语义：按 spec 路由 + fallback（spec → legacy → raw WS）
- `EndpointSpec/EndpointGroup` 的数据结构：能静态表达“接口可调用方式”
- `ApiError` 的归一化结构：上层可用同一结构做提示/告警/埋点

在不破坏上述契约的前提下，内部实现可以替换，例如：

- 把 WS 实现从当前协议替换为新协议，只要 `WsRpcClient.call` 语义不变
- 把 gRPC transport 从 grpc-web 切到 connect，只要 `GrpcClient.getService().method(req, opt)` 语义可映射
- 把路由策略从“静态 registry + pickEndpoint”升级为“策略引擎”，只要输入输出一致

### 常见改造需求与落点建议

| 需求 | 推荐落点 | 原因 |
|---|---|---|
| 全局请求链路追踪（traceId/spanId） | interceptor + auth.getHeaders | 不侵入每个接口定义，可统一注入 |
| 统一重试/退避 | interceptor（基于 isTransportError） | 在统一入口实现一次，三 transport 复用 |
| gRPC cookie/credentials 支持 | GrpcClient transport 配置 | 属于 transport 细节，不应泄漏到业务侧 |
| WS 请求响应匹配策略调整 | WsClient/WsRpcClient | 直接影响 requestId、responseFunctionName 等行为 |
| 业务错误码映射为用户提示 | normalizeApiError / onApiError | 保持 transport 无关，方便统一处理 |