NextSkin

Minecraft 皮肤站与外置登录
Skin hosting & Yggdrasil auth for Minecraft
TypeScript
Next.js
Hono
Kysely
Blessing Skin 的 TypeScript 全栈重写
A full-stack TypeScript rewrite of Blessing Skin Server

NextSkin

Skin hosting & Yggdrasil auth for Minecraft

Minecraft 皮肤站与外置登录。NextSkin 是 Blessing Skin Server 的 TypeScript 全栈重写:Next.js 做页面,Hono 做协议端点和 API,Kysely 连数据库。

和 Blessing Skin 用同一套协议,玩家的模组和启动器不用升级;旧站可以一键迁过来。

NextSkin

Minecraft 皮肤站与外置登录

Skin hosting & Yggdrasil auth for Minecraft

Blessing Skin Server 的 TypeScript 全栈重写:TypeScript · Next.js · Hono · Kysely。和 BS 同一套协议,玩家的模组和启动器不用升级,旧站可以一键迁过来。

01 部署 DEPLOY

一条命令,零原生依赖

One command. Zero native dependencies.

node nextskin.mjs start 启动整个站点:网页、协议端点、后台任务和插件进程都在里面,不用装 PHP,也不需要 Redis。

图像处理换成纯 TypeScript 的 PNG 管线,数据库驱动用 Node 内置的 node:sqlite。发行包里没有一个原生模块,同一个 ZIP 在 Linux、Windows、macOS 的 x64 和 arm64 上都能直接跑:需要 Node 24 以上,推荐 26。

01 部署 DEPLOY

一键升级,准备好了才停站

One-click upgrades. The site stays up until it's ready.

发行包能在后台一键升级。点“准备升级”时站点照常运行:下载 ZIP、核对 ed25519 签名和 SHA-256、解压到 releases/<版本>/,再用新版本对着在用的数据库预检。点“升级”以后才停掉旧版本、执行迁移、把 current.json 指向新版本、等它的 /api/health 返回 200;没有迁移时站点只停几秒。

新版本没通过健康检查时自动换回旧版本,SQLite 连同迁移前的备份一起换回;MySQL、PostgreSQL 有迁移时要先确认已经备份。上一个版本留在 releases/ 里,可以回到它。Docker 镜像只提示新版本,换标签升级。

02 兼容 COMPATIBLE

和 Blessing Skin 同一套协议

Same protocols as Blessing Skin

CustomSkinLoader、UniSkinMod、旧版材质地址和 Yggdrasil(authlib-injector)四种协议,路径、状态码、响应头和响应结构都和 Blessing Skin v6 一致。玩家的模组和启动器不用改配置,也不用升级。

玩家 UUID 创建以后永不改变,从旧站导入时原样保留:游戏服务器按 UUID 存背包和权限,换了就全对不上。

02 兼容 COMPATIBLE

从 BS 迁移:一个事务,全有或全无

Migrating from BS: one transaction, all or nothing

node nextskin.mjs import blessing-skin 只读地连上 Blessing Skin v6 的数据库,把用户、角色、材质、衣柜和站点设置在一个事务里整体导入;安装向导里也能直接点。

提交之前,在同一个事务里把每个角色读回来和旧库逐项比对:UUID、皮肤、披风、模型。有一项对不上就整体回滚,不会留下导了一半的站点。

03 数据库 DATABASES

四种数据库,一份代码

Four databases, one codebase

默认用 Node 内置的 SQLite,什么都不用装;也支持 MySQL、MariaDB 和 PostgreSQL。每一个数据库测试都在四种上跑。

同一个 upsert 在四种数据库上发出不同的语句:SQLite 和 PostgreSQL 用 ON CONFLICT;MySQL 和 MariaDB 先 UPDATE 拿排他锁、没有行再 INSERT,避开 InnoDB 共享锁升级的死锁。不需要 Redis。

04 自检 DOCTOR

出了问题,先问 doctor

When something's off, ask the doctor

node nextskin.mjs doctor 只读地检查运行环境:Node 版本、配置文件、密钥、数据目录、数据库连接和迁移状态、插件进程能不能带权限模型启动、服务在不在运行。有失败时退出码是 1,适合放进部署脚本。

忘了超级管理员的密码、两步验证丢了、验证码把自己挡在外面,也都有对应的命令。

05 玩家 PLAYERS

玩家要的,都有

Everything players expect

皮肤库、3D 预览、衣柜、本周收藏最多的领奖台、外置登录(authlib-injector,以及 Yggdrasil Connect 的设备码登录)、两步验证和通行密钥、GitHub / Microsoft / Google / LittleSkin 第三方登录、签到积分。

界面有亮色和暗色两种模式,默认跟随系统。默认配色是钻石青;官方插件“更多配色”另带钴蓝、洋红和高对比三套(高对比还换上直角、粗边框和带阴影的卡片),站长启用以后用户自己挑,每套都有亮暗两种。

接下来,是看不见的部分

上面是服主和玩家看得见的部分。下面几页讲 NextSkin 在底下下的功夫:材质怎么存、上传怎么查、协议怎么守、皮肤图怎么画、插件怎么隔离、后台任务怎么不丢。

06 材质 TEXTURES

材质按像素存,不按文件存

Textures are keyed by their pixels, not their bytes

同一张皮肤,换个编辑器保存、换个压缩级别,文件的 sha256 就全变了。NextSkin 的材质 hash 算的是像素:sha256(宽 ‖ 高 ‖ RGBA 像素)。上面九种文件解出来是同一组像素,hash 只有一个。

这个 hash 既是存储键,也是公开材质地址的一部分。换编码器、换 zlib 版本、换 CPU 架构都不会变;已经存下的文件也永远不重新编码。

06 材质 TEXTURES

上传的 PNG,先过安检

Every uploaded PNG gets inspected

上传的 PNG 逐块核对 CRC,只把 IHDR、PLTE、tRNS、IDAT、IEND 交给解码,色彩配置、文字、时间这些附加块一律丢掉。

解码之前先按 IHDR 的宽高算出解压后的精确长度。一个宽高只写 64×64 的文件也能解压出几百 MB:这样的解压炸弹在预算处就被截住,不会把服务器的内存吃光。

07 契约 CONTRACT

连 BS 的怪癖都照样复现,漏洞不复现

Its quirks are reproduced. Its security holes are not.

协议端点有 228 条契约用例:请求从真实的 Blessing Skin v6 录下响应,NextSkin 回放同样的请求逐项比较。玩家的模组和启动器依赖的怪癖也照样复现,比如角色名末尾多一个空格照样查得到、字符串 "false" 按 PHP 的规则算真。

安全漏洞是例外:用自己的令牌改别人的皮肤、按角色名退出登录却不吊销令牌,这些在 NextSkin 里有意修掉了,用例里写明了和 BS 的差别。

08 出图 RENDERING

皮肤图是服务端用纯 TS 一个个像素画出来的

Skin renders are rasterized on the server, in pure TypeScript

皮肤库和衣柜里的人物图不用 WebGL,也不用原生的图形库:packages/skin 把六个部位当成盒子,相机转 30°、俯视 18° 正交投影,再逐个面光栅化,深度缓冲决定谁挡住谁。

明暗是相机空间的固定光源,顶面、正面、侧面三档。三角函数写成字面量,因为各个 JS 引擎的 Math.cos 不保证逐位相同,出图要和黄金图逐像素比对。

鼠标悬浮时人物抬手,用的是 5 帧的拼图。颜色不到 256 种的图存成调色板 PNG,比 RGBA 编码小得多。

09 插件 PLUGINS

插件热更新:整个进程换一代

Plugin hot reload replaces the whole process

安装的插件不跑在核心进程里,而是跑在核心拉起的 plugin host 进程里:带 Node 的权限模型启动,环境变量是空的,拿不到数据库凭据,只能通过 RPC 调用核心。

升级插件时,核心拉起新一代 host、加载插件并核对实际的定义和 manifest 一致,再原子地把注册表切过去;旧一代等在途的调用结束后整个进程退出。ESM 模块卸载不掉,那就让整个进程退出。

09 插件 PLUGINS

插件的查询,由核心重建

Plugin queries are rebuilt by the core

插件也能用 Kysely 写自己的表,但 plugin host 发给核心的不是 SQL 字符串,而是 Kysely 的节点树。核心按白名单一个节点一个节点地重建,不是事后检查。

重建时表名改写成这个插件独占的物理表名,再用核心自己的编译器编译、在核心的事务里执行。原始 SQL 节点、没有声明过的表(比如 users)一律拒绝。

10 任务 JOBS

事件和业务写入,同一个事务提交

Events commit together with the data

注册一个用户时,写用户、加初始积分、记积分流水,再把 user.registered 事件按订阅它的插件展开成任务,全部在同一个事务里。任务表本身就是 outbox:提交了才看得见,回滚就一起消失。

几个实例同时认领同一个任务时,带条件的 UPDATE 只让一个成功。失败的任务按 10 秒起翻倍的间隔重试,最长 1 小时;5 次用完进死信表,后台可以重试。四种数据库同一套做法,不靠 SKIP LOCKED、LISTEN/NOTIFY,也不要 Redis。