- TypeScript 90.7%
- Shell 9.3%
Co-authored-by: Shu <shu@srcz.one> Reviewed-on: https://code.srcz.one/SourceZoneDev/mo4-version-api/pulls/1 |
||
|---|---|---|
| src | ||
| test | ||
| .gitattributes | ||
| .gitignore | ||
| compare.sh | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| wrangler.toml | ||
mo4-version-api · Cloudflare Workers 版
把 src/(.NET 10 / ASP.NET Core)那份搬到 Workers + KV。
对外行为除三条状态码外逐条照搬——那三条是 08-08 维护者修掉的原实现怪异处,见下表。
线上现址:https://mov.srcz.one/api/v1/version
为什么只改了存储
原实现把整个 Versions 文档写进一个本地文件(FilePath)。
Workers 的文件系统是临时的——PUT 写完,下次调用就没了。
这是 serverless 化唯一必须改的东西,其余(路由、认证、校验、错误体)逐条照搬。
存储选 KV 不选 D1:就一个 key、读多写少,D1 是杀鸡用牛刀。 ⚠️KV 是最终一致的,写完立刻在别的边缘节点读可能拿到旧值(秒级窗口)。 同节点写后读一致,自己验证基本落在同节点,实际影响很小。
行为对照(逐条钉在 test/behaviour.test.ts 里)
| 场景 | 响应 | 备注 |
|---|---|---|
GET 有数据 |
200 + 紧凑 JSON,带 ETag 与 Cache-Control |
字段序 release→test,内层 version→gameVersion→message→url |
HEAD |
200,无 body,Content-Length 与 GET 相同 |
✨08-08 加 |
GET 带 If-None-Match 命中 |
304,无 body,仍带 ETag/Cache-Control |
✨08-08 加 |
GET 空库 |
404 {"title":"版本信息不存在"} |
🔧08-08 改(原 500) |
PUT 无 Authorization |
401 {"title":"未授权"},WWW-Authenticate: Bearer |
✨08-08 加头 |
PUT 非 Bearer 开头 |
401 {"title":"无效的验证信息"},…error="invalid_request" |
前缀比较大小写不敏感 |
PUT token 不匹配 |
401 {"title":"无效的密钥"},…error="invalid_token" |
|
PUT JSON 语法坏 |
400 {"title":"无效的请求"} |
🔧08-08 改(原 500) |
PUT 字段缺失 |
400 ValidationProblem,errors + title |
title = "key: 不能为空",多项用 ; 连 |
PUT 版本号格式非法 |
400 ValidationProblem,errors["release.version"] |
🔧08-08 改(原 500 且不说哪个字段) |
PUT 成功 |
204 无响应体,带 ETag |
写完即知新标识,不必再 GET |
| 路径不匹配 | 404 {"title":"无效的请求"} |
|
OPTIONS 预检 |
204 + Allow-Methods/Allow-Headers/Max-Age |
✨08-08 加 |
| 已注册路径 + 未注册动词 | 405 {"title":"无效的请求"},带 Allow: GET, HEAD, PUT |
✨08-08 加 Allow |
错误体是 ASP.NET 的 ProblemDetails 形状:type(RFC 9110 锚点)、title、status,
Content-Type application/problem+json。
🔧 08-08 修掉的三条
原实现里这三种情况全部落进 UseExceptionHandler 变成 5xx——
服务器没错却报服务器错,客户端据此重试也没有意义。
| # | 原来 | 现在 | 为什么 |
|---|---|---|---|
| 1 | GET 空库 → 500「请求失败」 |
404「版本信息不存在」 | 资源不存在是 4xx;5xx 会让客户端以为该重试 |
| 2 | PUT JSON 坏 → 500「无效的请求」 |
400「无效的请求」 | 请求体坏是客户端的错 |
| 3 | 版本号格式非法 → 500,不说哪个字段 | 400 ValidationProblem,报到字段级 | 与"非空校验"共用一条出口,客户端能知道错在 release.version 还是 test.gameVersion |
原实现未严格遵循 HTTP 语义,本次一并修正。
⚠️这三条是行为变更,不是等价迁移——切换后若有客户端依赖旧状态码,会在这里出问题。
现有客户端只读 GET 的 200 正常路径,不受影响;写入端只有你自己。
✨ 08-08 加的四项标准件
原 .NET 实现连 401 该带的 WWW-Authenticate 都没有。这轮补齐:
| 项 | 依据 | 对这个 API 的实际意义 |
|---|---|---|
WWW-Authenticate |
RFC 9110 §15.5.2 规定 401 必须带;参数格式 RFC 6750 §3 | 客户端能区分"没带凭据"与"凭据不对" |
HEAD |
RFC 9110 §9.3.2:能 GET 的资源应能 HEAD | 只查是否变化时不必拉 body |
Cache-Control: public, max-age=60 |
— | 版本 API 被反复轮询,60 秒内直接用本地副本 |
ETag + If-None-Match |
RFC 9110 §8.8.3 / §13.1.2 | 未变化时回 304,一个字节的 body 都不传 |
Cache-Control: no-cache |
RFC 9111 §5.2.2.4 | "可以存但每次用前必须验"——⚠️不是"不缓存" |
实现上的两个细节:
- ETag 在 PUT 时算好存进 KV metadata,GET 直接读——GET 是高频路径,不该每次重算哈希。 手工灌入的数据没有 metadata,那时按内容现算兜底(有用例锁着)。
If-None-Match按标准处理了三种形状:*、逗号分隔列表、W/弱前缀。 这三条各自都能单独写错,所以各自有用例。
⚠️缓存策略最初写的是 public, max-age=60,查客户端时改掉了:
panel 保存完刷新页面,60 秒窗口里浏览器直接吃本地副本 ⇒ 看到自己刚改掉的旧值。
换成 no-cache 后每次回来验一下,未变化仍是 304、body 不传,省流量的效果没丢。
🚨 迁移必看:CORS 与预检(08-08 核对客户端源码时发现)
线上那几个 access-control-* 头不是 .NET 应用给的——Program.cs 里没有 UseCors,
它们是源站 nginx 加的。
Worker route 一拦截,请求就不回源,nginx 不参与,头跟着消失。
后果比"少个头"严重得多:
- panel(
https://mop.srcz.one)跨域调这个 API - 它的 PUT 带
Authorization+Content-Type: application/json⇒ 浏览器必定先发 OPTIONS 预检 - Worker 原先对 OPTIONS 返 405 ⇒ 预检失败,PUT 一次都发不出去
⇒ Worker 自己实现了 CORS 与预检,允许的 origin 走 ALLOWED_ORIGINS 配置。
同源请求不带 Origin 头,自动不加 CORS 头——将来 panel 与 API 同域时这里不用动。
⚠️CORS 头贴在唯一出口(fetch 的返回处),不散在各分支:
散着写必然漏掉某条错误路径,而漏掉的那条在浏览器里表现为
"请求失败但读不到状态码",是最难查的一类。错误响应带 CORS 有用例锁着。
🔑 这一条是读客户端源码 + 拉线上响应头才发现的—— 只读 API 自己的代码,它跑得好好的,什么都看不出来。
唯一有意偏离的一处
Token 比较从 string.Equals(Ordinal)(短路)换成恒定时间比较。
返回什么完全一致,只是不再泄漏"前几个字符对了"的时序信息。
已知的边界差异(实测,非推测)
url 字段两边都会做 URL 规范化(原实现是 new Uri(value).ToString())。
拿线上真实数据实测往返一致;下列形状两边行为也一致:
非 ASCII 路径转百分号编码、去掉默认端口 :443、空路径补 /、解析 ../。
⚠️已知一处不同:形如 %41 这种"百分号编码的非保留字符",
.NET 的 Uri.ToString() 会解码成 A,JS 的 URL.toString() 保留原样。
线上数据里没有这种形状;真要用到,得单独处理。
部署
⚠️命令按 wrangler v3 写(package.json 锁的是 ^3,实测装出 3.114.17)。
v3 的 kv key put 只有 --local、没有 --remote——不带 --local 默认就写远程。
若将来升到 v4,这几条要重新对一遍:照记忆写命令、不在真装的版本上跑一遍,
就是这一条把部署卡住的。
cd worker
npm install
# 1. 建 KV 命名空间,把返回的 id 填进 wrangler.toml
npx wrangler kv namespace create VERSIONS
# 2. 写入 token(⚠️不要写进 wrangler.toml,凭据不进仓库)
npx wrangler secret put TOKEN
# 3. 灌入现有数据(⚠️必做——否则 GET 会返 404)
curl -s https://mov.srcz.one/api/v1/version > /tmp/versions.json
npx wrangler kv key put --binding=VERSIONS versions --path=/tmp/versions.json
# 4. 发布
npx wrangler deploy
路由为什么用 route 不用 custom domain
wrangler.toml 里配的是 pattern = "mov.srcz.one/api/v1/version*"。
custom domain 会把整个 mov.srcz.one 收进 Worker,
而那台机上大概率还挂着别的 /api/* 服务——一绑就全抢走了。
route 只接管这一条路径,其余照旧回源。
⚠️切换前确认一下 mov.srcz.one 上还有哪些路径在用,别让 pattern 咬到别人。
回滚
删掉那条 route,流量立刻回原来的源站;KV 里的数据留着不动。 原 .NET 服务在切换稳定前不要停——这是回滚的唯一依靠。
测试
node --test test/behaviour.test.ts # 37 条,无需 CF 账号
npx tsc --noEmit # 类型检查
回归锁钉的是对外行为,不是内部实现。 它防的是下一个人的顺手:契约靠人记住是记不住的,改坏了要当场红, 而不是等客户端报上来。
⚠️08-08 改那三条状态码时,这套锁当场红了 5 条(3 条行为变更 + 2 条函数签名变更), 一条不漏——这就是它在干活的证据。改完再逐条更新用例,用例名里写清"维护者", 让"为什么与 .NET 版不同"这件事在代码里可追,而不是只活在聊天记录里。
客户端影响评估(08-08 逐仓读过源码,非推测)
| 客户端 | 调用方式 | 受影响吗 |
|---|---|---|
mo4-version-panel(Svelte/Astro,mop.srcz.one) |
浏览器 fetch,GET + 带 Token 的 PUT |
🔴 CORS 与预检必须补(见上);🟡 缓存策略已为它改成 no-cache |
mo4-version-func(RGSSTelemetry,.NET) |
HttpClient.GetFromJsonAsync |
🟢 不受影响 |
func 为什么安全(逐条核过,不是"应该没事"):
HttpClient默认不做 HTTP 缓存,也不发条件请求 ⇒ 拿不到 304,Cache-Control对它无意义GetFromJsonAsync对非 2xx 一律抛HttpRequestException,调用方ContinueWith里只Console.WriteLine⇒ GET 空库从 500 改 404,它的行为完全一样(都是静默跳过更新检查)- 非浏览器 ⇒ CORS 不适用
panel 对状态码改动是兼容且更好的:
错误分支读 res.statusText || data.title,而 400 ValidationProblem 的 title 现在是
"release.version: 版本号格式无效"——比原来那句笼统的「无效的请求」精确得多。