役次元 Yokonex Hub 开发文档

#大文件上传

超过单次请求上限的插件包用这套接口。如果你用 scripts/publish-plugin.mjs 发版, 它会自动判断走哪条路,这一页可以不看。


#为什么需要它

Workers 的单次请求体上限约 100MB,但插件市场里确实存在更大的包 —— 带完整 BepInEx 运行时、预编译 Mod 和资源的插件轻松过百兆。 所以大包必须切片传,而切片带来一个真问题:

全文件 SHA-256 怎么算?

哈希是顺序算法,而每个分片是独立的一次 HTTP 请求,Worker 之间不共享内存, 更不可能把 200MB 攒在内存里最后一起算(Worker 只有 128MB)。

做法是把 SHA-256 的中间态序列化后存进上传会话:8 个 32 位字 + 不足一块的尾巴 + 已处理字节数,一共 90 个字符。下一个分片来的时候恢复出来接着算。

代价:分片必须按顺序上传。服务端用 next_part 强制这一点。


#接口

#1. 开启会话

POST /api/developer/plugins/<source>/uploads
Content-Type: application/json
{ "version": "1.2.0", "size": 137428992, "sha256": "9a18bd67…" }

三个字段都是你自己先算好的声明值。服务端拿它们提前做几件事, 省得你白传 100MB 才被告知不行:

响应

{
  "uploadId": "upl_0mtv19nu338pk7f71oh",
  "partSize": 16777216,
  "totalParts": 9,
  "expiresAt": "2026-09-10T10:34:39.723Z",
  "note": "分片必须按 1..N 的顺序上传"
}

#2. 上传分片

PUT /api/developer/uploads/<uploadId>/parts/<n>
Content-Type: application/octet-stream

请求体就是第 n 片的原始字节。除最后一片外,每片必须正好是 partSize 字节 (R2 分片上传的硬性要求)。

{ "partNumber": 3, "receivedBytes": 50331648, "nextPart": 4, "done": false }
错误 含义
409 out_of_order 没按顺序传。响应里会告诉你现在该传第几片
400 bad_part_size 这一片的字节数不对
409 session_expired 会话超过 6 小时,重开

#3. 完成

POST /api/developer/uploads/<uploadId>/complete

服务端按顺序做这几件事,任何一步不过就整体失败、删对象、不落库:

  1. 哈希对账 —— 跨请求续算出的全文件 SHA-256 与开传时的声明比对。 这是最关键的一步:分片可以逐个成功,但只有最终哈希一致,才能证明拼出来的是同一个文件。
  2. 合并 —— 提交 R2 分片上传。
  3. 按需校验 —— 从 R2 range 读取尾部 EOCD、中央目录、manifest 那一小段, 跑与直传路径完全相同的那套安全判定(路径穿越、符号链接、zip 炸弹、manifest 合法性…)。 一个 200MB 的包实际读回来通常不到 100KB。
  4. 交叉对账 —— 包里 manifest 的 source / version 必须与开传时的声明一致。
  5. 发版判定 —— 所有权、版本递增、内容去重,与直传共用同一份实现。

成功返回与直传完全一样的结构。

#4. 放弃

DELETE /api/developer/uploads/<uploadId>

会同时中止 R2 侧的分片上传,不留垃圾对象。


#完整示例

import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const bytes = await readFile('big-plugin.zip');
const sha256 = createHash('sha256').update(bytes).digest('hex');

// 1. 开会话
const begin = await api('/api/developer/plugins/mygame/uploads', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ version: '1.2.0', size: bytes.length, sha256 }),
}).then((r) => r.json());

// 2. 按顺序传
for (let n = 1; n <= begin.totalParts; n++) {
  const start = (n - 1) * begin.partSize;
  await api(`/api/developer/uploads/${begin.uploadId}/parts/${n}`, {
    method: 'PUT',
    headers: { 'content-type': 'application/octet-stream' },
    body: bytes.subarray(start, Math.min(start + begin.partSize, bytes.length)),
  });
}

// 3. 完成
const result = await api(`/api/developer/uploads/${begin.uploadId}/complete`, {
  method: 'POST',
}).then((r) => r.json());

#限制

单包上限 200 MB
分片大小 16 MB(最后一片可以更小)
分片数上限 64
会话有效期 6 小时
解压后总大小上限 400 MB
单文件解压上限 200 MB
压缩比上限 200:1