@faapi/next 6.13.0 → 6.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/dist/client/index.d.ts +73 -0
- package/dist/client/index.js +60 -0
- package/dist/client/index.js.map +1 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -67,6 +67,36 @@ node dist/main
|
|
|
67
67
|
| `/api/chat`(faapi WS 路由) | faapi WebSocket handler |
|
|
68
68
|
| `/_next/webpack-hmr` | Next.js HMR |
|
|
69
69
|
|
|
70
|
+
## 浏览器端请求层(`@faapi/next/client`)
|
|
71
|
+
|
|
72
|
+
客户端组件(`'use client'`)的 API 请求统一走 `apiCall`,它封装了 fetch + faapi 信封(`{ data }` / `{ error }`)解包,失败一律抛结构化 `ApiError`(携带 `code` / `status` / `message`,`VALIDATION_ERROR` 时附 `issues`):
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
'use client';
|
|
76
|
+
import { apiCall, ApiError } from '@faapi/next/client';
|
|
77
|
+
|
|
78
|
+
async function submit() {
|
|
79
|
+
try {
|
|
80
|
+
const user = await apiCall<{ id: number }>('/api/user', { method: 'POST' });
|
|
81
|
+
toast.success('已保存');
|
|
82
|
+
} catch (e) {
|
|
83
|
+
toast.error(e instanceof Error ? e.message : String(e)); // message 恒为可读文案
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 为什么需要 apiCall
|
|
89
|
+
|
|
90
|
+
`/api/*` 的错误响应恒为 JSON 信封,但链路其他层会返回 HTML(反代/网关错误页、Next.js 404 页、SSO 登录守卫的 302 重定向)。直接 `res.json()` 会把裸 `Unexpected token '<'` SyntaxError 原文抛上界面。`apiCall` 把这些异常响应转译为可行动的中文提示(502/503 → 服务暂时不可用、504 → 服务响应超时、401 → 登录已过期、登录页重定向 → 登录状态已失效),排障现场(状态码 / URL / body 前 200 字符)留在 `console.error`。`statusMessage` 一并导出,可 fork 自定义文案。
|
|
91
|
+
|
|
92
|
+
### ⚠️ 必须用 `@faapi/next/client` 子路径导入
|
|
93
|
+
|
|
94
|
+
主入口 `@faapi/next` 是**服务端插件**(依赖 `@faapi/faapi` 与 next 服务端模块)。客户端组件若误用主入口,会把 `node:fs` / `node:child_process` 等服务端代码拉进浏览器 bundle,导致 Next.js build 失败。客户端代码只从 `@faapi/next/client` 导入;`./client` 入口零 Node 依赖,可安全进入客户端 bundle。
|
|
95
|
+
|
|
96
|
+
### 已知限制
|
|
97
|
+
|
|
98
|
+
`apiCall` 按主包 `config.response` 的**默认信封**解包。若你在 `faapi.config.ts` 自定义了 `response.ok` / `response.fail`,请自行包装 apiCall(替换解包段),不要两处各改各的。
|
|
99
|
+
|
|
70
100
|
## 许可证
|
|
71
101
|
|
|
72
102
|
[MIT](https://github.com/faapi/faapi/blob/main/LICENSE)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端统一错误类型与响应信封类型。
|
|
3
|
+
*
|
|
4
|
+
* 零依赖约束:本模块属于浏览器端代码(@faapi/next/client 子路径),禁止
|
|
5
|
+
* import @faapi/faapi 及任何 Node 模块——主包含 fs/child_process 等服务端
|
|
6
|
+
* 依赖,传递性引入会把服务端代码拉进浏览器 bundle,导致 Next.js 客户端
|
|
7
|
+
* 打包失败。类型独立声明、结构镜像主包,详见 apiError.md。
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 字段级校验详情,镜像主包 ValidationIssue(path/code/expected/received/message)。
|
|
11
|
+
*
|
|
12
|
+
* 服务端 JSON 的形状即客户端类型;主包结构变更时此处需手动同步。
|
|
13
|
+
*/
|
|
14
|
+
interface ApiValidationIssue {
|
|
15
|
+
/** 字段路径,如 'user.address.city' */
|
|
16
|
+
path: string;
|
|
17
|
+
/** 错误码,机器可读的契约,如 'TYPE_MISMATCH' */
|
|
18
|
+
code: string;
|
|
19
|
+
/** 期望类型/值,如 'number' */
|
|
20
|
+
expected: string;
|
|
21
|
+
/** 实际类型/值,如 'string' */
|
|
22
|
+
received: string;
|
|
23
|
+
/** 人类可读的字段级错误描述 */
|
|
24
|
+
message: string;
|
|
25
|
+
}
|
|
26
|
+
/** 失败信封:{ error: { message, ...code?, ...issues? } }(主包 defaultFail 中 code 省略时不存在) */
|
|
27
|
+
interface ApiErrorBody {
|
|
28
|
+
error: {
|
|
29
|
+
code?: string;
|
|
30
|
+
message: string;
|
|
31
|
+
issues?: ApiValidationIssue[];
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* faapi 默认响应信封:成功 { data } / 失败 { error }。
|
|
36
|
+
* 与主包 config.response.ok/fail 的默认实现一致;业务方自定义信封时
|
|
37
|
+
* 不适用(见 apiCall.md 已知限制)。
|
|
38
|
+
*/
|
|
39
|
+
type ApiEnvelope<T> = Partial<{
|
|
40
|
+
data: T;
|
|
41
|
+
}> & Partial<ApiErrorBody>;
|
|
42
|
+
/**
|
|
43
|
+
* API 错误:携带 code/status/issues,调用方可按 code 分支处理。
|
|
44
|
+
*
|
|
45
|
+
* 继承 Error,现有 `e instanceof Error ? e.message : String(e)` 的
|
|
46
|
+
* toast 消费代码不受影响。
|
|
47
|
+
*/
|
|
48
|
+
declare class ApiError extends Error {
|
|
49
|
+
/** 字符串业务错误码,如 'VALIDATION_ERROR';非信封错误为框架侧兜底码 */
|
|
50
|
+
readonly code: string;
|
|
51
|
+
/** HTTP 状态码 */
|
|
52
|
+
readonly status: number;
|
|
53
|
+
/** VALIDATION_ERROR 时的字段级校验详情,供表单回显 */
|
|
54
|
+
readonly issues?: readonly ApiValidationIssue[];
|
|
55
|
+
constructor(code: string, status: number, message: string, issues?: readonly ApiValidationIssue[]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 非 JSON 响应(反代错误页/空 body)按状态映射的可行动中文文案。
|
|
60
|
+
*
|
|
61
|
+
* 导出供业务方 fork 自定义文案(如英文产品)时组合使用。
|
|
62
|
+
*/
|
|
63
|
+
declare function statusMessage(status: number): string;
|
|
64
|
+
/**
|
|
65
|
+
* 发起 API 请求并解包 faapi 默认信封,失败一律抛 ApiError。
|
|
66
|
+
*
|
|
67
|
+
* 成功返回 body.data;非 JSON/信封错误/空响应转译为结构化错误,
|
|
68
|
+
* 现场经 console.error 保留(status/url/body 前 200 字符)。
|
|
69
|
+
* 仅支持主包 config.response 默认信封,自定义信封时业务方自行包装。
|
|
70
|
+
*/
|
|
71
|
+
declare function apiCall<T>(input: string, init?: RequestInit): Promise<T>;
|
|
72
|
+
|
|
73
|
+
export { type ApiEnvelope, ApiError, type ApiValidationIssue, apiCall, statusMessage };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/client/apiError.ts
|
|
2
|
+
var ApiError = class extends Error {
|
|
3
|
+
/** 字符串业务错误码,如 'VALIDATION_ERROR';非信封错误为框架侧兜底码 */
|
|
4
|
+
code;
|
|
5
|
+
/** HTTP 状态码 */
|
|
6
|
+
status;
|
|
7
|
+
/** VALIDATION_ERROR 时的字段级校验详情,供表单回显 */
|
|
8
|
+
issues;
|
|
9
|
+
constructor(code, status, message, issues) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "ApiError";
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.issues = issues;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/client/apiCall.ts
|
|
19
|
+
function statusMessage(status) {
|
|
20
|
+
if (status === 504) return "\u670D\u52A1\u54CD\u5E94\u8D85\u65F6,\u8BF7\u7A0D\u540E\u91CD\u8BD5";
|
|
21
|
+
if (status === 502 || status === 503) return "\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528,\u8BF7\u7A0D\u540E\u91CD\u8BD5";
|
|
22
|
+
if (status === 401) return "\u767B\u5F55\u5DF2\u8FC7\u671F,\u8BF7\u5237\u65B0\u9875\u9762\u91CD\u65B0\u767B\u5F55";
|
|
23
|
+
return `\u8BF7\u6C42\u5931\u8D25: ${status}`;
|
|
24
|
+
}
|
|
25
|
+
async function apiCall(input, init) {
|
|
26
|
+
const res = await fetch(input, init);
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
let body;
|
|
29
|
+
try {
|
|
30
|
+
body = JSON.parse(text);
|
|
31
|
+
} catch {
|
|
32
|
+
console.error("[apiCall] \u975E JSON \u54CD\u5E94", {
|
|
33
|
+
status: res.status,
|
|
34
|
+
url: res.url,
|
|
35
|
+
body: text.slice(0, 200)
|
|
36
|
+
});
|
|
37
|
+
if (res.redirected) {
|
|
38
|
+
throw new ApiError("REDIRECTED", res.status, "\u767B\u5F55\u72B6\u6001\u5DF2\u5931\u6548\uFF0C\u8BF7\u5237\u65B0\u9875\u9762\u91CD\u65B0\u767B\u5F55");
|
|
39
|
+
}
|
|
40
|
+
throw new ApiError("NON_JSON_RESPONSE", res.status, statusMessage(res.status));
|
|
41
|
+
}
|
|
42
|
+
if (!res.ok || body.error) {
|
|
43
|
+
throw new ApiError(
|
|
44
|
+
body.error?.code ?? "HTTP_ERROR",
|
|
45
|
+
res.status,
|
|
46
|
+
body.error?.message || statusMessage(res.status),
|
|
47
|
+
body.error?.issues
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (body.data === void 0) {
|
|
51
|
+
throw new ApiError("EMPTY_RESPONSE", res.status, `\u8BF7\u6C42\u5931\u8D25: ${res.status}`);
|
|
52
|
+
}
|
|
53
|
+
return body.data;
|
|
54
|
+
}
|
|
55
|
+
export {
|
|
56
|
+
ApiError,
|
|
57
|
+
apiCall,
|
|
58
|
+
statusMessage
|
|
59
|
+
};
|
|
60
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/client/apiError.ts","../../src/client/apiCall.ts"],"sourcesContent":["/**\n * 客户端统一错误类型与响应信封类型。\n *\n * 零依赖约束:本模块属于浏览器端代码(@faapi/next/client 子路径),禁止\n * import @faapi/faapi 及任何 Node 模块——主包含 fs/child_process 等服务端\n * 依赖,传递性引入会把服务端代码拉进浏览器 bundle,导致 Next.js 客户端\n * 打包失败。类型独立声明、结构镜像主包,详见 apiError.md。\n */\n\n/**\n * 字段级校验详情,镜像主包 ValidationIssue(path/code/expected/received/message)。\n *\n * 服务端 JSON 的形状即客户端类型;主包结构变更时此处需手动同步。\n */\nexport interface ApiValidationIssue {\n /** 字段路径,如 'user.address.city' */\n path: string;\n /** 错误码,机器可读的契约,如 'TYPE_MISMATCH' */\n code: string;\n /** 期望类型/值,如 'number' */\n expected: string;\n /** 实际类型/值,如 'string' */\n received: string;\n /** 人类可读的字段级错误描述 */\n message: string;\n}\n\n/** 失败信封:{ error: { message, ...code?, ...issues? } }(主包 defaultFail 中 code 省略时不存在) */\nexport interface ApiErrorBody {\n error: {\n code?: string;\n message: string;\n issues?: ApiValidationIssue[];\n };\n}\n\n/**\n * faapi 默认响应信封:成功 { data } / 失败 { error }。\n * 与主包 config.response.ok/fail 的默认实现一致;业务方自定义信封时\n * 不适用(见 apiCall.md 已知限制)。\n */\nexport type ApiEnvelope<T> = Partial<{ data: T }> & Partial<ApiErrorBody>;\n\n/**\n * API 错误:携带 code/status/issues,调用方可按 code 分支处理。\n *\n * 继承 Error,现有 `e instanceof Error ? e.message : String(e)` 的\n * toast 消费代码不受影响。\n */\nexport class ApiError extends Error {\n /** 字符串业务错误码,如 'VALIDATION_ERROR';非信封错误为框架侧兜底码 */\n readonly code: string;\n /** HTTP 状态码 */\n readonly status: number;\n /** VALIDATION_ERROR 时的字段级校验详情,供表单回显 */\n readonly issues?: readonly ApiValidationIssue[];\n\n constructor(\n code: string,\n status: number,\n message: string,\n issues?: readonly ApiValidationIssue[],\n ) {\n super(message);\n this.name = 'ApiError';\n this.code = code;\n this.status = status;\n this.issues = issues;\n }\n}\n","/**\n * 浏览器端统一请求入口:fetch 封装 + 非 JSON 响应守卫 + faapi 信封解包。\n *\n * 守卫动机:/api/* 的错误响应恒为 JSON 信封,但链路其他层会返回 HTML——\n * 反代/网关错误页(502/504)、Next.js 404 页、SSO 登录守卫 302(fetch 跟随\n * 重定向拿到登录页)。直接 res.json() 会把裸 SyntaxError 原文抛进界面;\n * 这里检测 + 转译为结构化 ApiError,现场留在 console。行为规格见 apiCall.md。\n */\nimport { ApiError, type ApiEnvelope } from './apiError';\n\n/**\n * 非 JSON 响应(反代错误页/空 body)按状态映射的可行动中文文案。\n *\n * 导出供业务方 fork 自定义文案(如英文产品)时组合使用。\n */\nexport function statusMessage(status: number): string {\n if (status === 504) return '服务响应超时,请稍后重试';\n if (status === 502 || status === 503) return '服务暂时不可用,请稍后重试';\n if (status === 401) return '登录已过期,请刷新页面重新登录';\n return `请求失败: ${status}`;\n}\n\n/**\n * 发起 API 请求并解包 faapi 默认信封,失败一律抛 ApiError。\n *\n * 成功返回 body.data;非 JSON/信封错误/空响应转译为结构化错误,\n * 现场经 console.error 保留(status/url/body 前 200 字符)。\n * 仅支持主包 config.response 默认信封,自定义信封时业务方自行包装。\n */\nexport async function apiCall<T>(input: string, init?: RequestInit): Promise<T> {\n const res = await fetch(input, init);\n const text = await res.text();\n\n let body: ApiEnvelope<T>;\n try {\n body = JSON.parse(text) as ApiEnvelope<T>;\n } catch {\n // 非 JSON 响应:现场留给排障,错误转译为可行动的文案。\n console.error('[apiCall] 非 JSON 响应', {\n status: res.status,\n url: res.url,\n body: text.slice(0, 200),\n });\n if (res.redirected) {\n // fetch 跟随重定向后拿到非 JSON(典型:SSO 登录守卫 302 → 登录页)\n throw new ApiError('REDIRECTED', res.status, '登录状态已失效,请刷新页面重新登录');\n }\n throw new ApiError('NON_JSON_RESPONSE', res.status, statusMessage(res.status));\n }\n\n if (!res.ok || body.error) {\n throw new ApiError(\n body.error?.code ?? 'HTTP_ERROR',\n res.status,\n body.error?.message || statusMessage(res.status),\n body.error?.issues,\n );\n }\n\n if (body.data === undefined) {\n // {data: null} 合法返回 null;无 data 字段({})视为空响应错误\n throw new ApiError('EMPTY_RESPONSE', res.status, `请求失败: ${res.status}`);\n }\n\n return body.data;\n}\n"],"mappings":";AAiDO,IAAM,WAAN,cAAuB,MAAM;AAAA;AAAA,EAEzB;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA,EAET,YACE,MACA,QACA,SACA,QACA;AACA,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,SAAS;AACd,SAAK,SAAS;AAAA,EAChB;AACF;;;ACtDO,SAAS,cAAc,QAAwB;AACpD,MAAI,WAAW,IAAK,QAAO;AAC3B,MAAI,WAAW,OAAO,WAAW,IAAK,QAAO;AAC7C,MAAI,WAAW,IAAK,QAAO;AAC3B,SAAO,6BAAS,MAAM;AACxB;AASA,eAAsB,QAAW,OAAe,MAAgC;AAC9E,QAAM,MAAM,MAAM,MAAM,OAAO,IAAI;AACnC,QAAM,OAAO,MAAM,IAAI,KAAK;AAE5B,MAAI;AACJ,MAAI;AACF,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB,QAAQ;AAEN,YAAQ,MAAM,sCAAuB;AAAA,MACnC,QAAQ,IAAI;AAAA,MACZ,KAAK,IAAI;AAAA,MACT,MAAM,KAAK,MAAM,GAAG,GAAG;AAAA,IACzB,CAAC;AACD,QAAI,IAAI,YAAY;AAElB,YAAM,IAAI,SAAS,cAAc,IAAI,QAAQ,wGAAmB;AAAA,IAClE;AACA,UAAM,IAAI,SAAS,qBAAqB,IAAI,QAAQ,cAAc,IAAI,MAAM,CAAC;AAAA,EAC/E;AAEA,MAAI,CAAC,IAAI,MAAM,KAAK,OAAO;AACzB,UAAM,IAAI;AAAA,MACR,KAAK,OAAO,QAAQ;AAAA,MACpB,IAAI;AAAA,MACJ,KAAK,OAAO,WAAW,cAAc,IAAI,MAAM;AAAA,MAC/C,KAAK,OAAO;AAAA,IACd;AAAA,EACF;AAEA,MAAI,KAAK,SAAS,QAAW;AAE3B,UAAM,IAAI,SAAS,kBAAkB,IAAI,QAAQ,6BAAS,IAAI,MAAM,EAAE;AAAA,EACxE;AAEA,SAAO,KAAK;AACd;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faapi/next",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.15.0",
|
|
4
4
|
"description": "Next.js integration for faapi — serve faapi APIs and Next.js pages from a single server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./dist/client/index.d.ts",
|
|
15
|
+
"import": "./dist/client/index.js"
|
|
12
16
|
}
|
|
13
17
|
},
|
|
14
18
|
"files": [
|
|
@@ -31,11 +35,11 @@
|
|
|
31
35
|
"vitest": "^4.1.11",
|
|
32
36
|
"ws": "^8.21.0",
|
|
33
37
|
"zod": "^4.4.3",
|
|
34
|
-
"@faapi/faapi": "6.
|
|
38
|
+
"@faapi/faapi": "6.15.0"
|
|
35
39
|
},
|
|
36
40
|
"peerDependencies": {
|
|
37
41
|
"next": ">=13.0.0",
|
|
38
|
-
"@faapi/faapi": "^6.
|
|
42
|
+
"@faapi/faapi": "^6.15.0"
|
|
39
43
|
},
|
|
40
44
|
"peerDependenciesMeta": {
|
|
41
45
|
"next": {
|