归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
Deploy to Production / pre-check (push) Successful in 58s
Deploy to Production / build (push) Successful in 4m3s
Deploy to Production / deploy-edgeone (push) Successful in 3m48s
Deploy to Production / finalize (push) Successful in 26s
Deploy to Production / notify-failure (push) Skipped
Deploy to Production / pre-check (push) Successful in 58s
Deploy to Production / build (push) Successful in 4m3s
Deploy to Production / deploy-edgeone (push) Successful in 3m48s
Deploy to Production / finalize (push) Successful in 26s
Deploy to Production / notify-failure (push) Skipped
This commit is contained in:
1 parent
855a001046
commit
a74b3c7127
98 files changed
+15657
No files matched your search
@@ -0,0 +1,173 @@
|
||||
import type { Env, UserRow } from './types';
|
||||
import { corsHeaders, json } from './lib/util';
|
||||
import { userFromToken } from './lib/session';
|
||||
import { friendlyError } from './lib/errors';
|
||||
|
||||
export interface Ctx {
|
||||
env: Env;
|
||||
req: Request;
|
||||
url: URL;
|
||||
params: Record<string, string>;
|
||||
user: UserRow | null;
|
||||
/**
|
||||
* 请求级后台任务(主要用于发邮件通知)。
|
||||
* 邮件走外部 HTTP API,跨境往返几百毫秒,绝不能阻塞评论提交;
|
||||
* 但注意:Worker 在响应返回后可能被冻结,waitUntil 注册的 Promise 会继续跑完。
|
||||
*/
|
||||
waitUntil?: (p: Promise<unknown>) => void;
|
||||
}
|
||||
|
||||
export type Handler = (ctx: Ctx) => Promise<Response>;
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
segs: string[];
|
||||
handler: Handler;
|
||||
}
|
||||
|
||||
export class Router {
|
||||
private routes: Route[] = [];
|
||||
|
||||
add(method: string, path: string, handler: Handler): this {
|
||||
this.routes.push({
|
||||
method: method.toUpperCase(),
|
||||
segs: path.split('/').filter(Boolean),
|
||||
handler,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
get(p: string, h: Handler) {
|
||||
return this.add('GET', p, h);
|
||||
}
|
||||
post(p: string, h: Handler) {
|
||||
return this.add('POST', p, h);
|
||||
}
|
||||
put(p: string, h: Handler) {
|
||||
return this.add('PUT', p, h);
|
||||
}
|
||||
del(p: string, h: Handler) {
|
||||
return this.add('DELETE', p, h);
|
||||
}
|
||||
all(p: string, h: Handler) {
|
||||
return this.add('*', p, h);
|
||||
}
|
||||
|
||||
private match(method: string, segs: string[]): { handler: Handler; params: Record<string, string> } | null {
|
||||
for (const r of this.routes) {
|
||||
if (r.method !== '*' && r.method !== method) continue;
|
||||
if (r.segs.length !== segs.length) continue;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
let ok = true;
|
||||
for (let i = 0; i < r.segs.length; i++) {
|
||||
const pat = r.segs[i];
|
||||
const val = segs[i];
|
||||
if (pat.startsWith(':')) {
|
||||
params[pat.slice(1).replace(/\?$/, '')] = decodeURIComponent(val);
|
||||
} else if (pat !== val) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) return { handler: r.handler, params };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同时挂在 / 和 /api/v2 两个前缀下:
|
||||
* Artalk 客户端请求的是 `${server}/api/v2/xxx`,但有人会直接打根路径。
|
||||
*/
|
||||
async dispatch(req: Request, env: Env, execCtx?: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const cors = corsHeaders(req, env);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: cors });
|
||||
}
|
||||
|
||||
const all = url.pathname.split('/').filter(Boolean);
|
||||
const candidates: string[][] = [all];
|
||||
if (all[0] === 'api' && all[1] === 'v2') candidates.unshift(all.slice(2));
|
||||
if (all[0] === 'api') candidates.push(all.slice(1));
|
||||
|
||||
for (const segs of candidates) {
|
||||
const hit = this.match(req.method.toUpperCase(), segs);
|
||||
if (!hit) continue;
|
||||
|
||||
try {
|
||||
// 本次请求挂一个 D1 会话(只读副本要靠它才会生效)。
|
||||
// ⚠️ sessionEnv / userFromToken 必须放在 try 里:userFromToken 会查 D1,
|
||||
// 额度用尽时它会抛错;以前它在 try 之外,于是整个 Worker 抛 500 且
|
||||
// **不带 CORS 头** → 浏览器只能报 "TypeError: Failed to fetch"。
|
||||
const scoped = sessionEnv(env, req);
|
||||
const user = await userFromToken(scoped, req.headers.get('Authorization'));
|
||||
|
||||
const res = await hit.handler({
|
||||
env: scoped,
|
||||
req,
|
||||
url,
|
||||
params: hit.params,
|
||||
user,
|
||||
waitUntil: execCtx ? (p: Promise<unknown>) => execCtx.waitUntil(p) : undefined,
|
||||
});
|
||||
return withCors(res, cors);
|
||||
} catch (e) {
|
||||
const err = friendlyError(e);
|
||||
console.error('[artalk-cf] handler error:', err.code, err.detail);
|
||||
// 明确告诉前端「哪一类故障」,前端据此给读者看不同的人话;
|
||||
// detail 保留原始信息,前端折叠展示,方便排查。
|
||||
return withCors(
|
||||
json({ msg: err.msg, code: err.code, detail: err.detail }, {
|
||||
status: err.status,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
}),
|
||||
cors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return withCors(
|
||||
json({ msg: `Not Found: ${req.method} ${url.pathname}` }, { status: 404 }),
|
||||
cors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function withCors(res: Response, cors: Headers): Response {
|
||||
const headers = new Headers(res.headers);
|
||||
cors.forEach((v, k) => headers.set(k, v));
|
||||
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* 给本次请求挂一个 D1 会话。
|
||||
*
|
||||
* D1 默认把**所有**查询都打主库(我们在 WNAM,读者在亚洲,每次读都要跨太平洋)。
|
||||
* 只有走 Sessions API,读请求才会被路由到就近的只读副本 —— 官方原话:
|
||||
* "To use read replication, you have to use the D1 Sessions API,
|
||||
* otherwise all queries will continue to be executed only by the primary database."
|
||||
*
|
||||
* 选型策略:
|
||||
* · 写请求(非 GET/HEAD/OPTIONS)→ first-primary
|
||||
* 写只能落主库;同一会话内「写后读」由 read-my-own-writes 保证
|
||||
* · 带 Authorization 的请求(管理面板)→ first-primary
|
||||
* 管理操作要求看到最新数据,刚删的评论不能还留在列表里
|
||||
* · 其余(读者流量,占绝大多数)→ first-unconstrained
|
||||
* 第一条查询就允许落到副本,延迟最低
|
||||
*
|
||||
* 同一会话内的多次查询具备顺序一致性(单调读 / 单调写 / 写随读 / 读己写),
|
||||
* 所以「查用户 → 查评论 → 查页面」不会读到互相错乱的版本。
|
||||
*
|
||||
* D1DatabaseSession 与 D1Database 共享 prepare()/batch(),可直接当 DB 用;
|
||||
* 但它**没有** exec()/dump()/withSession(),本项目没用到这三个。
|
||||
*/
|
||||
function sessionEnv(env: Env, req: Request): Env {
|
||||
const m = req.method.toUpperCase();
|
||||
const isWrite = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS';
|
||||
const authed = !!req.headers.get('Authorization');
|
||||
const mode: D1SessionConstraint = isWrite || authed ? 'first-primary' : 'first-unconstrained';
|
||||
const session = env.DB.withSession(mode);
|
||||
return { ...env, DB: session as unknown as D1Database };
|
||||
}
|
||||
Reference in new issue
Block a user