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
174 lines
6.1 KiB
TypeScript
174 lines
6.1 KiB
TypeScript
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 };
|
||
}
|