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; user: UserRow | null; /** * 请求级后台任务(主要用于发邮件通知)。 * 邮件走外部 HTTP API,跨境往返几百毫秒,绝不能阻塞评论提交; * 但注意:Worker 在响应返回后可能被冻结,waitUntil 注册的 Promise 会继续跑完。 */ waitUntil?: (p: Promise) => void; } export type Handler = (ctx: Ctx) => Promise; 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 } | null { for (const r of this.routes) { if (r.method !== '*' && r.method !== method) continue; if (r.segs.length !== segs.length) continue; const params: Record = {}; 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 { 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) => 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 }; }