@ai0x0/utils 0.7.0 → 0.7.1
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.
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api-route-via-nrf
|
|
3
|
+
* -----------------
|
|
4
|
+
* API 路由一律走 next-rest-framework(route / routeOperation),不许裸导 HTTP 方法。
|
|
5
|
+
*
|
|
6
|
+
* 裸写 `export async function POST(req)` 的代价不是风格不统一,是这条端点从此不存在于
|
|
7
|
+
* openapi.json:生成器不会给它出 client,CLI / agent 读不到它,前端只能手写 fetch 直连
|
|
8
|
+
* (那是 no-direct-api-url 拦的另一头)。框架统一做的鉴权预热、错误翻译、响应校验,
|
|
9
|
+
* 它也都得自己记得补。
|
|
10
|
+
*
|
|
11
|
+
* 判的是「导出形状」,不是「有没有 import route」:
|
|
12
|
+
* 报:`export async function POST(...)`、`export const GET = handler`;
|
|
13
|
+
* 放:`export const { POST } = route({...})`(从 route() 解构,id 是 ObjectPattern)。
|
|
14
|
+
*
|
|
15
|
+
* 作用范围由调用方在 flat config 里用 files 圈定(比如只在 api 目录的 route.ts 上开);
|
|
16
|
+
* 确有理由裸导的文件(第三方库的整体转发,如 better-auth 的 catch-all)用 files override
|
|
17
|
+
* 单独关掉,并在那个文件里写明理由。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const HTTP_METHODS = new Set([
|
|
21
|
+
"GET",
|
|
22
|
+
"POST",
|
|
23
|
+
"PUT",
|
|
24
|
+
"DELETE",
|
|
25
|
+
"PATCH",
|
|
26
|
+
"HEAD",
|
|
27
|
+
"OPTIONS",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
31
|
+
const rule = {
|
|
32
|
+
meta: {
|
|
33
|
+
type: "problem",
|
|
34
|
+
docs: {
|
|
35
|
+
description:
|
|
36
|
+
"API 路由必须用 next-rest-framework 的 route()/routeOperation() 构建,禁止裸导 HTTP 方法。",
|
|
37
|
+
},
|
|
38
|
+
schema: [],
|
|
39
|
+
messages: {
|
|
40
|
+
rawMethodExport:
|
|
41
|
+
"API 路由不许裸导 HTTP 方法,改用 next-rest-framework 的 route()/routeOperation" +
|
|
42
|
+
"(export const { POST } = route({...}))。裸导的端点进不了 openapi.json," +
|
|
43
|
+
"前端与 CLI 都拿不到它的 client。",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
create(context) {
|
|
47
|
+
return {
|
|
48
|
+
ExportNamedDeclaration(node) {
|
|
49
|
+
const declaration = node.declaration;
|
|
50
|
+
if (!declaration) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
declaration.type === "FunctionDeclaration" &&
|
|
55
|
+
declaration.id &&
|
|
56
|
+
HTTP_METHODS.has(declaration.id.name)
|
|
57
|
+
) {
|
|
58
|
+
context.report({
|
|
59
|
+
node: declaration.id,
|
|
60
|
+
messageId: "rawMethodExport",
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (declaration.type === "VariableDeclaration") {
|
|
65
|
+
for (const declarator of declaration.declarations) {
|
|
66
|
+
// 从 route() 解构出来的是 ObjectPattern,那才叫走框架。
|
|
67
|
+
if (
|
|
68
|
+
declarator.id.type === "Identifier" &&
|
|
69
|
+
HTTP_METHODS.has(declarator.id.name)
|
|
70
|
+
) {
|
|
71
|
+
context.report({
|
|
72
|
+
node: declarator.id,
|
|
73
|
+
messageId: "rawMethodExport",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default rule;
|
package/eslint-rules/index.js
CHANGED
|
@@ -12,6 +12,8 @@ import noThen from "./no-then.js";
|
|
|
12
12
|
import noOneLetterVars from "./no-one-letter-vars.js";
|
|
13
13
|
import noConsecutiveSetState from "./no-consecutive-setstate.js";
|
|
14
14
|
import maxLines from "./max-lines.js";
|
|
15
|
+
import apiRouteViaNrf from "./api-route-via-nrf.js";
|
|
16
|
+
import noDirectApiUrl from "./no-direct-api-url.js";
|
|
15
17
|
|
|
16
18
|
const rules = {
|
|
17
19
|
"require-section-divider": requireSectionDivider,
|
|
@@ -25,8 +27,17 @@ const rules = {
|
|
|
25
27
|
"no-one-letter-vars": noOneLetterVars,
|
|
26
28
|
"no-consecutive-setstate": noConsecutiveSetState,
|
|
27
29
|
"max-lines": maxLines,
|
|
30
|
+
"api-route-via-nrf": apiRouteViaNrf,
|
|
31
|
+
"no-direct-api-url": noDirectApiUrl,
|
|
28
32
|
};
|
|
29
33
|
|
|
34
|
+
// 这两条只管「特定位置」的代码,不能跟着 recommended 全局开:
|
|
35
|
+
// · api-route-via-nrf 只该挂在 API 路由文件上(别的文件 export 一个叫 GET 的函数是自由的);
|
|
36
|
+
// · no-direct-api-url 只该挂在前端目录上(后端与中间件里出现 "/api/..." 字面量是本分)。
|
|
37
|
+
// 调用方在 flat config 里用 files 圈定范围自行开启,豁免文件用 files override 关掉,
|
|
38
|
+
// 用法写在两条规则各自的文件头。
|
|
39
|
+
const SCOPED_RULES = new Set(["api-route-via-nrf", "no-direct-api-url"]);
|
|
40
|
+
|
|
30
41
|
const plugin = {
|
|
31
42
|
rules,
|
|
32
43
|
configs: {
|
|
@@ -34,7 +45,9 @@ const plugin = {
|
|
|
34
45
|
plugins: { ai0x0: { rules } },
|
|
35
46
|
rules: {
|
|
36
47
|
...Object.fromEntries(
|
|
37
|
-
Object.keys(rules)
|
|
48
|
+
Object.keys(rules)
|
|
49
|
+
.filter((name) => !SCOPED_RULES.has(name))
|
|
50
|
+
.map((name) => [`ai0x0/${name}`, "error"]),
|
|
38
51
|
),
|
|
39
52
|
curly: "error",
|
|
40
53
|
"ai0x0/max-lines": [
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* no-direct-api-url
|
|
3
|
+
* -----------------
|
|
4
|
+
* 前端不许手写 "/api/..." 地址直连后端,一律走生成的 client。
|
|
5
|
+
*
|
|
6
|
+
* 手写 `fetch("/api/xxx")` 绕掉的不是一层方便,是一整套集中处理(401 重登、错误提示、
|
|
7
|
+
* 作用域头、重复请求去重),而且 TypeScript 对它是瞎的 —— 路由改了形状,编译不红。
|
|
8
|
+
* 所以要调一个端点:先让它进 openapi.json(后端路由走 nrf,见 api-route-via-nrf),
|
|
9
|
+
* 重新生成 client,再调生成出来的方法。
|
|
10
|
+
*
|
|
11
|
+
* 判的是字符串字面量与模板串的开头:`/api/`。注释与 JSDoc 不是 AST 节点,天然不报。
|
|
12
|
+
* 作用范围由调用方在 flat config 里用 files 圈定(比如只挂前端目录),client 封装层
|
|
13
|
+
* 自己(生成文件与拦截器)用 files override 关掉 —— 地址本来就该只写在那儿。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const API_PREFIX = "/api/";
|
|
17
|
+
|
|
18
|
+
function startsWithApi(value) {
|
|
19
|
+
return typeof value === "string" && value.startsWith(API_PREFIX);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
23
|
+
const rule = {
|
|
24
|
+
meta: {
|
|
25
|
+
type: "problem",
|
|
26
|
+
docs: {
|
|
27
|
+
description:
|
|
28
|
+
'前端禁止手写 "/api/..." 地址直连后端,必须走生成的 client。',
|
|
29
|
+
},
|
|
30
|
+
schema: [],
|
|
31
|
+
messages: {
|
|
32
|
+
directApiUrl:
|
|
33
|
+
'前端不许手写 "/api/..." 直连后端:走生成的 client。端点先进 openapi.json' +
|
|
34
|
+
"(后端路由走 nrf),重新生成 client 之后调生成出来的方法;401 重登、错误提示、" +
|
|
35
|
+
"作用域头都在那一层集中处理,手写 fetch 全部绕开。",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
create(context) {
|
|
39
|
+
return {
|
|
40
|
+
Literal(node) {
|
|
41
|
+
if (startsWithApi(node.value)) {
|
|
42
|
+
context.report({ node, messageId: "directApiUrl" });
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
TemplateLiteral(node) {
|
|
46
|
+
// 只看第一段静态文本:`${base}/api/x` 的地址不归这条管(那是封装层的拼法)。
|
|
47
|
+
if (startsWithApi(node.quasis[0]?.value.cooked)) {
|
|
48
|
+
context.report({ node, messageId: "directApiUrl" });
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default rule;
|