@usethink/cf-core 0.3.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 +88 -0
- package/dist/features/anti-abuse/index.d.ts +62 -0
- package/dist/features/anti-abuse/index.d.ts.map +1 -0
- package/dist/features/anti-abuse/index.js +139 -0
- package/dist/features/anti-abuse/index.js.map +1 -0
- package/dist/features/email/index.d.ts +68 -0
- package/dist/features/email/index.d.ts.map +1 -0
- package/dist/features/email/index.js +120 -0
- package/dist/features/email/index.js.map +1 -0
- package/dist/features/payment/fetch-utils.d.ts +17 -0
- package/dist/features/payment/fetch-utils.d.ts.map +1 -0
- package/dist/features/payment/fetch-utils.js +56 -0
- package/dist/features/payment/fetch-utils.js.map +1 -0
- package/dist/features/payment/index.d.ts +20 -0
- package/dist/features/payment/index.d.ts.map +1 -0
- package/dist/features/payment/index.js +21 -0
- package/dist/features/payment/index.js.map +1 -0
- package/dist/features/payment/providers/alipay.d.ts +30 -0
- package/dist/features/payment/providers/alipay.d.ts.map +1 -0
- package/dist/features/payment/providers/alipay.js +149 -0
- package/dist/features/payment/providers/alipay.js.map +1 -0
- package/dist/features/payment/providers/stripe.d.ts +34 -0
- package/dist/features/payment/providers/stripe.d.ts.map +1 -0
- package/dist/features/payment/providers/stripe.js +168 -0
- package/dist/features/payment/providers/stripe.js.map +1 -0
- package/dist/features/payment/providers/trc20.d.ts +24 -0
- package/dist/features/payment/providers/trc20.d.ts.map +1 -0
- package/dist/features/payment/providers/trc20.js +96 -0
- package/dist/features/payment/providers/trc20.js.map +1 -0
- package/dist/features/payment/registry.d.ts +43 -0
- package/dist/features/payment/registry.d.ts.map +1 -0
- package/dist/features/payment/registry.js +65 -0
- package/dist/features/payment/registry.js.map +1 -0
- package/dist/features/payment/types.d.ts +72 -0
- package/dist/features/payment/types.d.ts.map +1 -0
- package/dist/features/payment/types.js +8 -0
- package/dist/features/payment/types.js.map +1 -0
- package/dist/features/thompson-router/index.d.ts +101 -0
- package/dist/features/thompson-router/index.d.ts.map +1 -0
- package/dist/features/thompson-router/index.js +186 -0
- package/dist/features/thompson-router/index.js.map +1 -0
- package/dist/features/webhook/index.d.ts +76 -0
- package/dist/features/webhook/index.d.ts.map +1 -0
- package/dist/features/webhook/index.js +127 -0
- package/dist/features/webhook/index.js.map +1 -0
- package/dist/src/audit.d.ts +45 -0
- package/dist/src/audit.d.ts.map +1 -0
- package/dist/src/audit.js +40 -0
- package/dist/src/audit.js.map +1 -0
- package/dist/src/auth/jwt.d.ts +33 -0
- package/dist/src/auth/jwt.d.ts.map +1 -0
- package/dist/src/auth/jwt.js +87 -0
- package/dist/src/auth/jwt.js.map +1 -0
- package/dist/src/auth/password.d.ts +26 -0
- package/dist/src/auth/password.d.ts.map +1 -0
- package/dist/src/auth/password.js +52 -0
- package/dist/src/auth/password.js.map +1 -0
- package/dist/src/bootstrap.d.ts +74 -0
- package/dist/src/bootstrap.d.ts.map +1 -0
- package/dist/src/bootstrap.js +231 -0
- package/dist/src/bootstrap.js.map +1 -0
- package/dist/src/cache.d.ts +52 -0
- package/dist/src/cache.d.ts.map +1 -0
- package/dist/src/cache.js +76 -0
- package/dist/src/cache.js.map +1 -0
- package/dist/src/config.d.ts +83 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +96 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/crypto.d.ts +33 -0
- package/dist/src/crypto.d.ts.map +1 -0
- package/dist/src/crypto.js +87 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/db/connection.d.ts +53 -0
- package/dist/src/db/connection.d.ts.map +1 -0
- package/dist/src/db/connection.js +104 -0
- package/dist/src/db/connection.js.map +1 -0
- package/dist/src/db/index.d.ts +6 -0
- package/dist/src/db/index.d.ts.map +1 -0
- package/dist/src/db/index.js +6 -0
- package/dist/src/db/index.js.map +1 -0
- package/dist/src/db/schema.d.ts +649 -0
- package/dist/src/db/schema.d.ts.map +1 -0
- package/dist/src/db/schema.js +76 -0
- package/dist/src/db/schema.js.map +1 -0
- package/dist/src/error.d.ts +47 -0
- package/dist/src/error.d.ts.map +1 -0
- package/dist/src/error.js +94 -0
- package/dist/src/error.js.map +1 -0
- package/dist/src/http.d.ts +83 -0
- package/dist/src/http.d.ts.map +1 -0
- package/dist/src/http.js +116 -0
- package/dist/src/http.js.map +1 -0
- package/dist/src/idempotency.d.ts +78 -0
- package/dist/src/idempotency.d.ts.map +1 -0
- package/dist/src/idempotency.js +84 -0
- package/dist/src/idempotency.js.map +1 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +45 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/logger.d.ts +31 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +45 -0
- package/dist/src/logger.js.map +1 -0
- package/dist/src/middleware/admin-auth.d.ts +38 -0
- package/dist/src/middleware/admin-auth.d.ts.map +1 -0
- package/dist/src/middleware/admin-auth.js +55 -0
- package/dist/src/middleware/admin-auth.js.map +1 -0
- package/dist/src/middleware/api-key-auth.d.ts +42 -0
- package/dist/src/middleware/api-key-auth.d.ts.map +1 -0
- package/dist/src/middleware/api-key-auth.js +104 -0
- package/dist/src/middleware/api-key-auth.js.map +1 -0
- package/dist/src/middleware/index.d.ts +3 -0
- package/dist/src/middleware/index.d.ts.map +1 -0
- package/dist/src/middleware/index.js +3 -0
- package/dist/src/middleware/index.js.map +1 -0
- package/dist/src/rate-limit.d.ts +54 -0
- package/dist/src/rate-limit.d.ts.map +1 -0
- package/dist/src/rate-limit.js +134 -0
- package/dist/src/rate-limit.js.map +1 -0
- package/dist/src/security.d.ts +78 -0
- package/dist/src/security.d.ts.map +1 -0
- package/dist/src/security.js +175 -0
- package/dist/src/security.js.map +1 -0
- package/dist/src/types.d.ts +64 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +8 -0
- package/dist/src/types.js.map +1 -0
- package/features/anti-abuse/index.ts +180 -0
- package/features/anti-abuse/tests/index.test.ts +50 -0
- package/features/email/index.ts +172 -0
- package/features/email/tests/index.test.ts +44 -0
- package/features/payment/fetch-utils.ts +65 -0
- package/features/payment/index.ts +39 -0
- package/features/payment/providers/alipay.ts +171 -0
- package/features/payment/providers/stripe.ts +192 -0
- package/features/payment/providers/trc20.ts +115 -0
- package/features/payment/registry.ts +87 -0
- package/features/payment/tests/index.test.ts +506 -0
- package/features/payment/types.ts +93 -0
- package/features/telegram-miniapp/index.ts +109 -0
- package/features/telegram-miniapp/tests/index.test.ts +11 -0
- package/features/thompson-router/index.ts +243 -0
- package/features/thompson-router/tests/index.test.ts +93 -0
- package/features/webhook/index.ts +183 -0
- package/features/webhook/tests/index.test.ts +21 -0
- package/package.json +202 -0
- package/src/audit.ts +70 -0
- package/src/auth/jwt.ts +114 -0
- package/src/auth/password.ts +75 -0
- package/src/bootstrap.ts +322 -0
- package/src/cache.ts +78 -0
- package/src/config.ts +134 -0
- package/src/crypto.ts +106 -0
- package/src/db/connection.ts +127 -0
- package/src/db/index.ts +6 -0
- package/src/db/schema.ts +90 -0
- package/src/error.ts +125 -0
- package/src/http.ts +150 -0
- package/src/idempotency.ts +127 -0
- package/src/index.ts +85 -0
- package/src/logger.ts +63 -0
- package/src/middleware/admin-auth.ts +71 -0
- package/src/middleware/api-key-auth.ts +164 -0
- package/src/middleware/index.ts +2 -0
- package/src/rate-limit.ts +167 -0
- package/src/security.ts +219 -0
- package/src/types.ts +70 -0
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker 入口工厂
|
|
3
|
+
*
|
|
4
|
+
* 将三项目 index.ts 中高度重复的 Worker 启动逻辑抽象为统一工厂函数:
|
|
5
|
+
* - 数据库初始化中间件
|
|
6
|
+
* - 请求体大小限制
|
|
7
|
+
* - 安全响应头注入
|
|
8
|
+
* - API 路由 / 静态资源分流
|
|
9
|
+
* - 全局错误处理
|
|
10
|
+
*
|
|
11
|
+
* 来源:eshop/xtools/vcode 三项目 index.ts 入口合并
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Hono } from "hono";
|
|
15
|
+
import { initDatabaseWithHealthCheck, type DrizzleInstance } from "./db/connection";
|
|
16
|
+
import { fail } from "./http";
|
|
17
|
+
import { buildSecurityHeaders, type SecurityHeadersOptions } from "./security";
|
|
18
|
+
|
|
19
|
+
export interface BootstrapOptions<TSchema extends Record<string, unknown> = Record<string, never>> {
|
|
20
|
+
/** Drizzle schema(传入后支持关系查询) */
|
|
21
|
+
schema?: TSchema;
|
|
22
|
+
|
|
23
|
+
/** API 路由前缀,默认 "/api" */
|
|
24
|
+
apiPrefix?: string;
|
|
25
|
+
|
|
26
|
+
/** 请求体大小限制(字节),默认 100KB */
|
|
27
|
+
maxBodySize?: number;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 请求级超时(毫秒),默认 25000(25 秒)。
|
|
31
|
+
* Cloudflare Workers 硬限制 30 秒,留 5 秒余量给 CF 运行时。
|
|
32
|
+
* 超时后请求会被中止并返回 504 Gateway Timeout。
|
|
33
|
+
*/
|
|
34
|
+
requestTimeoutMs?: number;
|
|
35
|
+
|
|
36
|
+
/** 安全响应头配置 */
|
|
37
|
+
securityHeaders?: SecurityHeadersOptions;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 错误告警回调 — 生产环境错误通知。
|
|
41
|
+
* 当请求处理过程中发生未捕获错误时调用,可用于发送到外部告警系统
|
|
42
|
+
* (如 Slack webhook、邮件、PagerDuty 等)。
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* onErrorAlert: async (error, context) => {
|
|
47
|
+
* await fetch("https://hooks.slack.com/...", {
|
|
48
|
+
* method: "POST",
|
|
49
|
+
* body: JSON.stringify({ text: `[ERROR] ${error.message}` }),
|
|
50
|
+
* });
|
|
51
|
+
* }
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
onErrorAlert?: (error: Error, context: { path: string; method: string; ip?: string }) => void | Promise<void>;
|
|
55
|
+
|
|
56
|
+
/** 注册 API 路由的回调 */
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
registerRoutes: (api: Hono<any>) => void;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 静态资源路由表 — 页面路径到 HTML 文件的映射
|
|
62
|
+
* @example { "/admin": "/admin.html", "/shop": "/index.html" }
|
|
63
|
+
*/
|
|
64
|
+
pageRoutes?: Record<string, string>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* SPA 路由 — 这些路径都返回同一个 HTML 文件
|
|
68
|
+
* @example { fallback: "/_app/index.html", paths: ["/shop", "/order"] }
|
|
69
|
+
*/
|
|
70
|
+
spaRoutes?: {
|
|
71
|
+
fallback: string;
|
|
72
|
+
paths: string[];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 长缓存路径前缀(如 /_app/assets/)
|
|
77
|
+
* 匹配的路径会设置 Cache-Control: immutable, max-age=31536000
|
|
78
|
+
*/
|
|
79
|
+
immutablePrefixes?: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 创建 Cloudflare Workers 应用
|
|
84
|
+
*
|
|
85
|
+
* 返回标准的 Workers fetch handler 导出对象。
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* import * as schema from "./db/schema";
|
|
90
|
+
* import { bootstrap } from "@usethink/cf-core/bootstrap";
|
|
91
|
+
*
|
|
92
|
+
* export default bootstrap({
|
|
93
|
+
* schema,
|
|
94
|
+
* registerRoutes: (api) => {
|
|
95
|
+
* api.route("/products", productRoutes);
|
|
96
|
+
* api.route("/orders", orderRoutes);
|
|
97
|
+
* },
|
|
98
|
+
* pageRoutes: { "/admin": "/admin.html" },
|
|
99
|
+
* spaRoutes: { fallback: "/_app/index.html", paths: ["/shop", "/order"] },
|
|
100
|
+
* immutablePrefixes: ["/_app/assets/"],
|
|
101
|
+
* });
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
/** 生成友好的 HTML 错误页面(内联 CSS,无外部依赖) */
|
|
105
|
+
function errorPageHtml(status: number, message: string): string {
|
|
106
|
+
return `<!DOCTYPE html>
|
|
107
|
+
<html lang="zh-CN">
|
|
108
|
+
<head>
|
|
109
|
+
<meta charset="UTF-8">
|
|
110
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
111
|
+
<title>${status} - ${message}</title>
|
|
112
|
+
<style>
|
|
113
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
114
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f0f0f;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
|
|
115
|
+
.container{text-align:center;max-width:480px}
|
|
116
|
+
.code{font-size:6rem;font-weight:800;background:linear-gradient(135deg,#6c63ff,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;line-height:1}
|
|
117
|
+
.msg{font-size:1.25rem;color:#888;margin:1rem 0 2rem}
|
|
118
|
+
a{color:#6c63ff;text-decoration:none;font-weight:500}
|
|
119
|
+
a:hover{text-decoration:underline}
|
|
120
|
+
</style>
|
|
121
|
+
</head>
|
|
122
|
+
<body>
|
|
123
|
+
<div class="container">
|
|
124
|
+
<p class="code">${status}</p>
|
|
125
|
+
<p class="msg">${message}</p>
|
|
126
|
+
<a href="/">← 返回首页</a>
|
|
127
|
+
</div>
|
|
128
|
+
</body>
|
|
129
|
+
</html>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function bootstrap<TSchema extends Record<string, unknown>>(
|
|
133
|
+
options: BootstrapOptions<TSchema>,
|
|
134
|
+
) {
|
|
135
|
+
const {
|
|
136
|
+
schema,
|
|
137
|
+
apiPrefix = "/api",
|
|
138
|
+
maxBodySize = 1024 * 100,
|
|
139
|
+
requestTimeoutMs = 25_000,
|
|
140
|
+
securityHeaders: secOpts = {},
|
|
141
|
+
onErrorAlert,
|
|
142
|
+
registerRoutes,
|
|
143
|
+
pageRoutes = {},
|
|
144
|
+
spaRoutes,
|
|
145
|
+
immutablePrefixes = [],
|
|
146
|
+
} = options;
|
|
147
|
+
|
|
148
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
149
|
+
const api = new Hono<any>();
|
|
150
|
+
|
|
151
|
+
// ── 请求级超时中间件(防止慢请求耗尽 Workers CPU) ──
|
|
152
|
+
api.use("*", async (c, next) => {
|
|
153
|
+
// /health 不受超时限制(用于监控探活)
|
|
154
|
+
if (c.req.path === "/health") {
|
|
155
|
+
await next();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
const timeoutId = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
// 将 signal 传递给下游(路由可选择性使用)
|
|
164
|
+
c.set("abortSignal", controller.signal);
|
|
165
|
+
await next();
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
168
|
+
console.error(`[bootstrap:timeout] 请求超时 ${requestTimeoutMs}ms: ${c.req.method} ${c.req.path}`);
|
|
169
|
+
return fail(c, "请求处理超时", 504);
|
|
170
|
+
}
|
|
171
|
+
throw err;
|
|
172
|
+
} finally {
|
|
173
|
+
clearTimeout(timeoutId);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── DB 初始化中间件(带连接验证 + 重试) ──
|
|
178
|
+
api.use("*", async (c, next) => {
|
|
179
|
+
const isHealth = c.req.path === "/health";
|
|
180
|
+
try {
|
|
181
|
+
const db = await initDatabaseWithHealthCheck(c.env.TURSO_URL, c.env.TURSO_TOKEN, schema);
|
|
182
|
+
c.set("db", db);
|
|
183
|
+
if ("executionCtx" in c) {
|
|
184
|
+
c.set("executionCtx", c.env.executionCtx);
|
|
185
|
+
}
|
|
186
|
+
await next();
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error("[bootstrap:db-init]", err);
|
|
189
|
+
if (isHealth) {
|
|
190
|
+
c.set("db", undefined);
|
|
191
|
+
await next();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
return fail(c, "服务暂时不可用", 503);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── 请求体大小限制 ──
|
|
199
|
+
api.use("*", async (c, next) => {
|
|
200
|
+
const len = parseInt(c.req.header("content-length") || "0");
|
|
201
|
+
if (len > maxBodySize) return fail(c, `请求体过大(最大 ${Math.floor(maxBodySize / 1024)}KB)`, 413);
|
|
202
|
+
await next();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ── 注册业务路由 ──
|
|
206
|
+
registerRoutes(api);
|
|
207
|
+
|
|
208
|
+
// ── 404 + 全局错误(浏览器返回 HTML,API 返回 JSON) ──
|
|
209
|
+
api.notFound((c) => {
|
|
210
|
+
const accept = c.req.header("accept") || "";
|
|
211
|
+
if (accept.includes("text/html")) {
|
|
212
|
+
return c.html(errorPageHtml(404, "页面未找到"), 404);
|
|
213
|
+
}
|
|
214
|
+
return fail(c, "API not found", 404);
|
|
215
|
+
});
|
|
216
|
+
api.onError((error, c) => {
|
|
217
|
+
console.error("[bootstrap:onError]", error?.constructor?.name, error?.message, error?.stack);
|
|
218
|
+
|
|
219
|
+
// 触发告警回调(如果配置了)
|
|
220
|
+
if (onErrorAlert) {
|
|
221
|
+
const ip = c.req.header("cf-connecting-ip") || c.req.header("x-forwarded-for") || "unknown";
|
|
222
|
+
// 异步发送告警,不阻塞响应
|
|
223
|
+
Promise.resolve(onErrorAlert(error as Error, {
|
|
224
|
+
path: c.req.path,
|
|
225
|
+
method: c.req.method,
|
|
226
|
+
ip,
|
|
227
|
+
})).catch((alertErr) => {
|
|
228
|
+
console.error("[bootstrap:onErrorAlert] 告警发送失败:", alertErr);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const accept = c.req.header("accept") || "";
|
|
233
|
+
if (accept.includes("text/html")) {
|
|
234
|
+
return c.html(errorPageHtml(500, "服务暂时不可用"), 500);
|
|
235
|
+
}
|
|
236
|
+
return fail(c, "服务暂时不可用", 500, { error: error?.message });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ── 预计算安全响应头 ──
|
|
240
|
+
const defaultHeaders = buildSecurityHeaders(secOpts);
|
|
241
|
+
const adminHeaders = buildSecurityHeaders({ ...secOpts, allowUnsafeEval: true });
|
|
242
|
+
|
|
243
|
+
function applyHeaders(response: Response, isImmutable = false, isAdmin = false): Response {
|
|
244
|
+
const headers = new Headers(response.headers);
|
|
245
|
+
const base = isAdmin ? adminHeaders : defaultHeaders;
|
|
246
|
+
for (const [k, v] of base) headers.set(k, v);
|
|
247
|
+
if (isImmutable) {
|
|
248
|
+
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
249
|
+
}
|
|
250
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
async fetch(request: Request, env: Record<string, unknown>, ctx: ExecutionContext) {
|
|
255
|
+
try {
|
|
256
|
+
const url = new URL(request.url);
|
|
257
|
+
const path = url.pathname;
|
|
258
|
+
|
|
259
|
+
// API 路由 → Hono
|
|
260
|
+
const prefix = apiPrefix.replace(/\/$/, "");
|
|
261
|
+
if (path === prefix || path.startsWith(`${prefix}/`)) {
|
|
262
|
+
url.pathname = path.replace(new RegExp(`^${prefix}`), "") || "/";
|
|
263
|
+
return api.fetch(new Request(url, request), env, ctx);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 静态资源处理需要 ASSETS binding
|
|
267
|
+
const assets = env.ASSETS as Fetcher | undefined;
|
|
268
|
+
if (!assets) {
|
|
269
|
+
return new Response(JSON.stringify({ ok: false, error: "ASSETS binding not configured" }), {
|
|
270
|
+
status: 500,
|
|
271
|
+
headers: { "content-type": "application/json" },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 长缓存静态资源
|
|
276
|
+
if (immutablePrefixes.some((p) => path.startsWith(p))) {
|
|
277
|
+
return applyHeaders(await assets.fetch(request), true);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 页面路由(pageRoutes 精确匹配)
|
|
281
|
+
for (const [route, file] of Object.entries(pageRoutes)) {
|
|
282
|
+
if (path === route || path.startsWith(`${route}/`)) {
|
|
283
|
+
url.pathname = file;
|
|
284
|
+
const isAdmin = route === "/admin" || route.startsWith("/admin");
|
|
285
|
+
return applyHeaders(await assets.fetch(new Request(url, request)), false, isAdmin);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// SPA 路由
|
|
290
|
+
if (spaRoutes && spaRoutes.paths.some((p) => path === p || path.startsWith(`${p}/`))) {
|
|
291
|
+
url.pathname = spaRoutes.fallback;
|
|
292
|
+
const res = await assets.fetch(new Request(url, request));
|
|
293
|
+
if (res.ok) return applyHeaders(res);
|
|
294
|
+
url.pathname = "/index.html";
|
|
295
|
+
return applyHeaders(await assets.fetch(new Request(url, request)));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 根路径
|
|
299
|
+
if (path === "/") {
|
|
300
|
+
url.pathname = "/index.html";
|
|
301
|
+
return applyHeaders(await assets.fetch(new Request(url, request)));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 其他静态资源
|
|
305
|
+
return applyHeaders(await assets.fetch(request));
|
|
306
|
+
} catch (err) {
|
|
307
|
+
console.error("[bootstrap:fetch]", err);
|
|
308
|
+
const accept = request.headers.get("accept") || "";
|
|
309
|
+
if (accept.includes("text/html")) {
|
|
310
|
+
return new Response(errorPageHtml(500, "服务暂时不可用"), {
|
|
311
|
+
status: 500,
|
|
312
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return new Response(JSON.stringify({ ok: false, error: "服务暂时不可用" }), {
|
|
316
|
+
status: 500,
|
|
317
|
+
headers: { "content-type": "application/json" },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workers Cache API 封装
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare Free 套餐的 Cache API 完全免费,不计入 10 万次/天的请求限制。
|
|
5
|
+
* 这是 Free 套餐下唯一能免费扩容的手段,必须充分利用。
|
|
6
|
+
*
|
|
7
|
+
* 使用场景:
|
|
8
|
+
* - GET /products(商品列表,TTL 5 分钟)
|
|
9
|
+
* - GET /system-config(系统配置,TTL 30 分钟)
|
|
10
|
+
* - 任何读多写少的 API 响应
|
|
11
|
+
*
|
|
12
|
+
* 来源:eshop src/lib/cache.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 创建命名空间化的 Cache 实例。
|
|
17
|
+
*
|
|
18
|
+
* @param namespace - 缓存命名空间(通常为项目名,如 "eshop-v1")
|
|
19
|
+
*/
|
|
20
|
+
export function createCache(namespace: string) {
|
|
21
|
+
function key(path: string, query?: string): string {
|
|
22
|
+
const base = `https://cache.local/${namespace}${path}`;
|
|
23
|
+
return query ? `${base}?${query}` : base;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
/**
|
|
28
|
+
* 从 Cache API 读取
|
|
29
|
+
*/
|
|
30
|
+
async get(cacheKey: string): Promise<Response | null> {
|
|
31
|
+
try {
|
|
32
|
+
const match = await caches.default.match(cacheKey);
|
|
33
|
+
return match || null;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.warn("[cache] get failed:", err);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 写入 Cache API
|
|
42
|
+
*/
|
|
43
|
+
async put(cacheKey: string, response: Response, ttlSeconds: number): Promise<void> {
|
|
44
|
+
try {
|
|
45
|
+
const clone = response.clone();
|
|
46
|
+
const headers = new Headers(clone.headers);
|
|
47
|
+
headers.set("Cache-Control", `public, max-age=${ttlSeconds}`);
|
|
48
|
+
headers.set("CF-Cache-Status", "HIT");
|
|
49
|
+
const cachedResponse = new Response(clone.body, {
|
|
50
|
+
status: clone.status,
|
|
51
|
+
statusText: clone.statusText,
|
|
52
|
+
headers,
|
|
53
|
+
});
|
|
54
|
+
await caches.default.put(cacheKey, cachedResponse);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.warn("[cache] put failed:", err);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 删除缓存
|
|
62
|
+
*/
|
|
63
|
+
async delete(cacheKey: string): Promise<boolean> {
|
|
64
|
+
try {
|
|
65
|
+
return await caches.default.delete(cacheKey);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.warn("[cache] delete failed:", err);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/** 缓存键生成器(暴露供外部使用) */
|
|
73
|
+
key,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 默认实例(无命名空间,适用于单项目场景) */
|
|
78
|
+
export const cache = createCache("default");
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 运行时系统配置模块(热生效 KV 存储)
|
|
3
|
+
*
|
|
4
|
+
* 使用 system_config 表存储运行时配置,支持热生效(无需重启)。
|
|
5
|
+
* 可选启用 Cache API 缓存以减少数据库查询。
|
|
6
|
+
*
|
|
7
|
+
* 三项目均有 system_config 表且结构完全一致。
|
|
8
|
+
*
|
|
9
|
+
* 来源:eshop/xtools/vcode system_config 表 + eshop cache.ts 合并
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { eq } from "drizzle-orm";
|
|
13
|
+
import { systemConfig } from "./db/schema";
|
|
14
|
+
|
|
15
|
+
interface ConfigDbLike {
|
|
16
|
+
select: (cols: { value: typeof systemConfig.value }) => {
|
|
17
|
+
from: (table: typeof systemConfig) => {
|
|
18
|
+
where: (cond: unknown) => {
|
|
19
|
+
limit: (n: number) => Promise<{ value: string }[]>;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
insert: (table: typeof systemConfig) => {
|
|
24
|
+
values: (data: { key: string; value: string; updatedAt: string }) => {
|
|
25
|
+
onConflictDoUpdate: (opts: {
|
|
26
|
+
target: typeof systemConfig.key;
|
|
27
|
+
set: { value: string; updatedAt: string };
|
|
28
|
+
}) => Promise<unknown>;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
delete: (table: typeof systemConfig) => {
|
|
32
|
+
where: (cond: unknown) => Promise<unknown>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SystemConfigOptions {
|
|
37
|
+
/** 内存缓存 TTL(毫秒),0 表示不缓存,默认 5 分钟 */
|
|
38
|
+
cacheTtlMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 系统配置管理器
|
|
43
|
+
*/
|
|
44
|
+
export class SystemConfig {
|
|
45
|
+
private db: ConfigDbLike;
|
|
46
|
+
private cache = new Map<string, { value: string; expiresAt: number }>();
|
|
47
|
+
private cacheTtlMs: number;
|
|
48
|
+
|
|
49
|
+
constructor(db: ConfigDbLike, options: SystemConfigOptions = {}) {
|
|
50
|
+
this.db = db;
|
|
51
|
+
this.cacheTtlMs = options.cacheTtlMs ?? 5 * 60 * 1000; // 5 分钟
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 读取配置值
|
|
56
|
+
*
|
|
57
|
+
* 优先从内存缓存读取,过期后从数据库重新加载。
|
|
58
|
+
*/
|
|
59
|
+
async get(key: string, defaultValue = ""): Promise<string> {
|
|
60
|
+
// 内存缓存
|
|
61
|
+
if (this.cacheTtlMs > 0) {
|
|
62
|
+
const cached = this.cache.get(key);
|
|
63
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
64
|
+
return cached.value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const [row] = await this.db
|
|
70
|
+
.select({ value: systemConfig.value })
|
|
71
|
+
.from(systemConfig)
|
|
72
|
+
.where(eq(systemConfig.key, key))
|
|
73
|
+
.limit(1);
|
|
74
|
+
|
|
75
|
+
const value = row?.value ?? defaultValue;
|
|
76
|
+
|
|
77
|
+
if (this.cacheTtlMs > 0) {
|
|
78
|
+
this.cache.set(key, { value, expiresAt: Date.now() + this.cacheTtlMs });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return value;
|
|
82
|
+
} catch {
|
|
83
|
+
return defaultValue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 读取配置值并解析为数字
|
|
89
|
+
*/
|
|
90
|
+
async getNumber(key: string, defaultValue: number): Promise<number> {
|
|
91
|
+
const raw = await this.get(key, String(defaultValue));
|
|
92
|
+
const num = Number(raw);
|
|
93
|
+
return Number.isFinite(num) ? num : defaultValue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 读取配置值并解析为布尔
|
|
98
|
+
*/
|
|
99
|
+
async getBoolean(key: string, defaultValue = false): Promise<boolean> {
|
|
100
|
+
const raw = await this.get(key, String(defaultValue));
|
|
101
|
+
return raw === "true" || raw === "1";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 写入配置值(UPSERT)
|
|
106
|
+
*/
|
|
107
|
+
async set(key: string, value: string): Promise<void> {
|
|
108
|
+
await this.db
|
|
109
|
+
.insert(systemConfig)
|
|
110
|
+
.values({ key, value, updatedAt: new Date().toISOString() })
|
|
111
|
+
.onConflictDoUpdate({
|
|
112
|
+
target: systemConfig.key,
|
|
113
|
+
set: { value, updatedAt: new Date().toISOString() },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 清除内存缓存
|
|
117
|
+
this.cache.delete(key);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 删除配置
|
|
122
|
+
*/
|
|
123
|
+
async delete(key: string): Promise<void> {
|
|
124
|
+
await this.db.delete(systemConfig).where(eq(systemConfig.key, key));
|
|
125
|
+
this.cache.delete(key);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 清除所有内存缓存(用于强制刷新)
|
|
130
|
+
*/
|
|
131
|
+
clearCache(): void {
|
|
132
|
+
this.cache.clear();
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-256-GCM 加解密模块
|
|
3
|
+
*
|
|
4
|
+
* 使用 Web Crypto API(Workers 原生),零外部依赖。
|
|
5
|
+
* 凭据在写入数据库前加密,读取后解密,确保数据库中不存明文。
|
|
6
|
+
*
|
|
7
|
+
* 密钥来源:环境变量(32 字节 hex = 64 字符 = 256 bit)
|
|
8
|
+
* 格式:iv(12B) + ciphertext + authTag(16B),整体 base64 编码存储
|
|
9
|
+
*
|
|
10
|
+
* 来源:xtools src/lib/crypto.ts
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const ALGO = "AES-GCM";
|
|
14
|
+
const IV_LENGTH = 12;
|
|
15
|
+
|
|
16
|
+
/** 安全地将 Uint8Array 编码为 base64(分块避免大数组展开栈溢出) */
|
|
17
|
+
function arrayToBase64(bytes: Uint8Array): string {
|
|
18
|
+
const chunks: string[] = [];
|
|
19
|
+
const chunkSize = 8192;
|
|
20
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
21
|
+
const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
|
|
22
|
+
chunks.push(String.fromCharCode(...chunk));
|
|
23
|
+
}
|
|
24
|
+
return btoa(chunks.join(""));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 安全地将 base64 解码为 Uint8Array */
|
|
28
|
+
function base64ToArray(base64: string): Uint8Array {
|
|
29
|
+
const binary = atob(base64);
|
|
30
|
+
const bytes = new Uint8Array(binary.length);
|
|
31
|
+
for (let i = 0; i < binary.length; i++) {
|
|
32
|
+
bytes[i] = binary.charCodeAt(i);
|
|
33
|
+
}
|
|
34
|
+
return bytes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 从 hex 字符串导入 AES-256-GCM 密钥 */
|
|
38
|
+
async function importKey(rawHex: string): Promise<CryptoKey> {
|
|
39
|
+
if (!rawHex || rawHex.length !== 64) {
|
|
40
|
+
throw new Error("加密密钥必须为 64 字符 hex(32 字节 / 256 bit)");
|
|
41
|
+
}
|
|
42
|
+
const keyBytes = new Uint8Array(rawHex.match(/.{2}/g)!.map((b) => parseInt(b, 16)));
|
|
43
|
+
return crypto.subtle.importKey("raw", keyBytes, { name: ALGO }, false, ["encrypt", "decrypt"]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 加密 JSON 对象 → base64 字符串
|
|
48
|
+
*/
|
|
49
|
+
export async function encrypt(
|
|
50
|
+
data: Record<string, unknown>,
|
|
51
|
+
encryptionKeyHex: string,
|
|
52
|
+
): Promise<string> {
|
|
53
|
+
const key = await importKey(encryptionKeyHex);
|
|
54
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
55
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
|
56
|
+
|
|
57
|
+
const cipherBuffer = await crypto.subtle.encrypt({ name: ALGO, iv }, key, plaintext);
|
|
58
|
+
|
|
59
|
+
const combined = new Uint8Array(iv.length + cipherBuffer.byteLength);
|
|
60
|
+
combined.set(iv, 0);
|
|
61
|
+
combined.set(new Uint8Array(cipherBuffer), iv.length);
|
|
62
|
+
|
|
63
|
+
return arrayToBase64(combined);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 解密 base64 字符串 → JSON 对象
|
|
68
|
+
*/
|
|
69
|
+
export async function decrypt(
|
|
70
|
+
encryptedBase64: string,
|
|
71
|
+
encryptionKeyHex: string,
|
|
72
|
+
): Promise<Record<string, unknown>> {
|
|
73
|
+
const key = await importKey(encryptionKeyHex);
|
|
74
|
+
|
|
75
|
+
const combined = base64ToArray(encryptedBase64);
|
|
76
|
+
const iv = combined.slice(0, IV_LENGTH);
|
|
77
|
+
const ciphertext = combined.slice(IV_LENGTH);
|
|
78
|
+
|
|
79
|
+
const plainBuffer = await crypto.subtle.decrypt({ name: ALGO, iv }, key, ciphertext);
|
|
80
|
+
return JSON.parse(new TextDecoder().decode(plainBuffer));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 检查加密密钥是否已配置
|
|
85
|
+
*/
|
|
86
|
+
export function isEncryptionAvailable(env: { CREDENTIALS_ENCRYPTION_KEY?: string }): boolean {
|
|
87
|
+
return !!(env.CREDENTIALS_ENCRYPTION_KEY && env.CREDENTIALS_ENCRYPTION_KEY.length === 64);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 生成 UUID v4(兼容 Cloudflare Workers)
|
|
92
|
+
*
|
|
93
|
+
* 使用 crypto.getRandomValues()(100% Workers 支持),
|
|
94
|
+
* 不依赖 crypto.randomUUID()(部分旧版本不支持)。
|
|
95
|
+
*/
|
|
96
|
+
export function generateUUID(): string {
|
|
97
|
+
const bytes = new Uint8Array(16);
|
|
98
|
+
crypto.getRandomValues(bytes);
|
|
99
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40; // v4
|
|
100
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant
|
|
101
|
+
|
|
102
|
+
const hex = Array.from(bytes)
|
|
103
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
104
|
+
.join("");
|
|
105
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
106
|
+
}
|