Files
blog/blog-admin/src/router.ts
T
zqlit a74b3c7127
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
归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
2026-10-04 08:45:40 +08:00

174 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}