Files
blog/blog-admin/src/router.ts
T

173 lines
6.1 KiB
TypeScript
Raw Normal View History

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 };
}