宿舍里有两台台式机、一台 Mac 笔记本,还有四台手机。有时候需要在这些设备之间传图片、文件或者一段文字,尤其是同一份东西要给多台设备用的时候,用微信、QQ 来回转就比较麻烦,还涉及设备登录的问题。
于是做了一个局域网共享中转板。各台设备用浏览器打开,内容发上去以后,其他设备可以直接查看、复制或者下载。服务端会保留历史记录,接收设备晚一点上线也能拿到。
项目最早是 2023 年自己手搓的,一个简单的 server.js 配上 index.html 就搞定了。能用是能用,但好用就根本说不上了。
到了今年,做个人项目时经常需要在设备之间传东西,原来的版本已经跟不上需求了。正好手里有 token,就用 Claude Code 给这个共享中转板做了一次大幅改版。起初先优化传输,后面为了日常使用方便,陆续补了预览、批量上传、搜索、PWA 等功能。这里主要记录一下文件传输和部署中遇到的问题。
旧版服务端只有一个文件,Express 负责返回页面和提供文件下载,Socket.IO 负责收发消息。
文字消息收到以后,放进一个数组,再广播给所有连接的设备。新设备连接时,把这个数组发过去,作为历史消息:
const messages = [];
io.on('connection', (socket) => {
socket.emit('history', messages);
socket.on('chat message', (msg) => {
const MSG = {
msg: msg,
time: formatDate(new Date(), 'YYYY-MM-DD hh:mm:ss')
};
messages.push(MSG);
io.emit('chat message', MSG);
});
});
文件也是从 Socket.IO 发过来,保存到 uploads 目录,再生成一段下载链接,按普通消息广播。
所以最初的版本已经是共享消息流:一台设备发出去,其他设备都能看到。这次改版保留了这个用法,主要调整文件传输、数据保存和访问方式。
现在的结构大致如下:
手机 / 电脑浏览器
│
HTTPS
│
Caddy 反向代理
│
Node.js 服务
├─ Express:页面、上传、下载、鉴权
├─ Socket.IO:文字消息、条目变化通知
└─ 本地磁盘:文件、消息记录、配置
前端仍然使用原生 HTML、CSS 和 JavaScript,没有额外的构建步骤。
旧页面还从公网 CDN 加载 Socket.IO 客户端。现在改为服务端提供的 /socket.io/socket.io.js,页面的这项依赖也一起放到了本地服务中。
先看旧版怎么上传文件。
前端通过 FileReader 把整个文件读成 ArrayBuffer,读取结束后再发送:
const reader = new FileReader();
reader.onload = (event) => {
const fileData = {
fileName: file.name,
file: event.target.result,
};
socket.emit('file', fileData);
};
reader.readAsArrayBuffer(file);
服务端收到完整内容后直接写入磁盘。旧代码中的关键部分是:
const fileName = `uploads/${fileData.fileName}`;
await fs.writeFileSync(fileName, fileData.file);
这条链路很直接,但有两个问题。
首先,浏览器需要先完整读取文件,才能执行发送。文件越大,这一步占用的内存和等待时间就越明显,服务端也按整块文件数据接收和处理。
其次,writeFileSync 是同步写入。前面加了 await,也不会把它变成异步操作。写入期间会阻塞当前 Node.js 进程继续处理其他事件。
新版把文件上传改成 HTTP multipart,使用 busboy 解析,再把文件流接到磁盘写入流上。核心部分如下,省略路径生成和异常处理:
const bb = Busboy({
headers: req.headers,
defParamCharset: 'utf8',
limits: {
fileSize: maxFileBytes,
files: 50,
},
});
bb.on('file', (fieldname, stream, info) => {
const ws = fs.createWriteStream(filePath);
stream.pipe(ws);
});
req.pipe(bb);
这样文件可以边接收边写入,不需要应用先攒出一份完整文件再保存。流的缓冲机制也能协调接收和写入的速度。
这里仍然有内存和磁盘开销,只是避免了旧版主动完整读取文件、再整块处理的方式。当前默认单文件限制为 2 GiB,一批最多 50 个文件,不能把这个配置上限直接当成性能测试结果。
defParamCharset: 'utf8' 则是处理中文文件名时补上的。文件内容能正常上传,不代表 multipart 中的文件名也一定按预期解码,这部分需要单独处理。
文件保存完成后,服务端通过 Socket.IO 广播新条目的信息。其他设备先更新消息流,需要预览或下载时,再请求文件接口。
这样一台电脑上传文件,不会把整个文件内容主动推给所有在线设备。手机只想看看文件名,就不需要先接收完整附件。
文字消息仍然走 Socket.IO。文件上传和实时通知分开后,两边可以分别处理大小限制、上传进度和消息同步。
旧版直接用原始文件名作为磁盘路径:
const fileName = `uploads/${fileData.fileName}`;
如果两次都上传 截图.png,后一次会写入同一个路径,覆盖前一次的文件。消息列表里虽然有两条记录,链接却指向同一个位置。
现在每个文件有独立 ID,磁盘文件名组合成:
<文件 ID>__<处理后的原文件名>
原文件名用于展示,文件 ID 用于寻址。这样两台设备上传同名文件,也可以分别保存。
下载时,客户端通过文件 ID 请求接口,服务端在当前空间的记录中查找,再定位磁盘文件,不直接把用户提供的文件名当成访问路径。
旧版文件上传完成后,服务端会拼一段 HTML:
const message = {
msg: `<a href="${fileName}" target="_blank">File: ${fileData.fileName}</a>`,
time: formatDate(new Date(), 'YYYY-MM-DD hh:mm:ss'),
};
前端再通过 innerHTML 放进页面。文字消息也使用了同样的渲染方式。
这样写起来很快,但消息内容和显示方式混在了一起。后面要判断这条消息是图片、视频还是普通文件,就不能只靠一段下载链接了。直接把传入内容作为 HTML 渲染,也需要额外考虑安全问题。
新版保存的是结构化数据。下面是省略部分字段后的示例:
{
id: 'message-id',
type: 'files',
ts: 1790000000000,
expiresAt: 1790604800000,
files: [
{
id: 'file-id',
name: '截图.png',
size: 123456,
mime: 'image/png',
ext: 'png'
}
]
}
前端根据条目类型选择渲染方式:图片显示预览,音视频显示播放器,其他文件显示文件卡片。文字和文件名则通过 textContent 写入。
一次选择多个文件,也可以放在同一条消息的 files 数组中。之后做批量下载、文件类型筛选,都有明确的数据可以使用。
旧版虽然有历史消息,但历史只存在这里:
const messages = [];
服务持续运行时,新连接的设备可以收到之前的消息。一旦重启进程,这个数组就重新变成空数组。
文件本身还留在 uploads 目录中,但消息列表里已经没有它们的记录。所以“文件保存了”和“历史消息保存了”是两件事。
新版仍然在内存中维护消息,方便查询和广播,同时把元数据保存到 JSON 文件中。启动时先读取已有数据,后面新设备连接,再发送当前空间的条目。
目前每个空间分别保存消息和文件。条目包含创建时间、过期时间和文件信息,默认保留七天,清理时一起移除记录和对应文件。磁盘上没有条目引用的文件,也会经过宽限期后清理。
元数据保存采用临时文件替换:
function persistNow() {
const tmp = `${boardPath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ items, tags }));
fs.renameSync(tmp, boardPath);
}
先把完整内容写到临时文件,再替换目标文件,减少直接覆盖原文件时留下半截 JSON 的问题。
这里仍然使用同步文件操作,处理的是消息元数据,不是上传文件本体。当前数据量较小,这个实现比较直接;如果元数据规模继续增大,全量序列化和同步写入也会成为需要调整的地方。
保存任务会延后约 500ms,把短时间内的多次修改合并。因此成功响应返回时,最近的修改可能还没有落盘。这种方式没有每次操作立即持久化的保证。
另外,它只适用于单写者。同一份数据目录不能同时交给生产容器和宿主机上的另一个 Node.js 进程使用,否则两边各持有一份内存状态,后保存的一方可能覆盖前一方的修改。
旧版文件处理主要就是写入、创建消息、广播,没有把失败过程单独组织起来。
新版加入批量上传后,一次操作会同时影响文件、消息条目和容量统计。假设一批有五个文件,前四个写完,第五个失败,就需要决定这批内容是否还能出现在消息流里。
当前以整个批次为一条消息处理:
前面的检查失败,就清理临时文件。后面的步骤出错,则尝试撤销已创建的条目、删除文件,并扣回用量。
文件和 JSON 元数据没有放在同一个事务中,这里做的是应用层的一致性处理和尽力回滚。
请求体读完后,服务端可能还在等待磁盘写入,也可能正在处理消息入账。此时不能简单根据请求流结束,就判断客户端取消或者操作成功。
项目通过响应的关闭事件辅助判断连接是否提前结束:
let clientGone = false;
res.on('close', () => {
if (!res.writableEnded) {
clientGone = true;
}
});
如果响应还没结束,连接就关闭了,先记录状态。后续执行到提交前的检查点,发现客户端已经断开,就清理临时文件,不再创建消息。
请求流、文件写入流和响应有各自的生命周期,这几个结束时机需要分开处理。
前端上传失败后,会自动重试一次。如果仍然失败,就把该批次保留在失败列表里,用户可以手动重试。主动取消则不自动重传。
这里会重新发送整批文件,没有复用上一次已经上传的字节。
下载接口使用 res.sendFile(),当前依赖中的实现可以处理 Range 请求。但下载端能够返回指定范围的文件内容,不代表上传端也实现了续传。
真正做上传续传,还需要记录上传会话、已收到的偏移量或分片,并处理校验、最终提交和过期清理。客户端恢复时先查询服务端进度,再发送剩余内容。
重试还涉及幂等问题:服务端可能已经提交成功,只是响应没能返回客户端。客户端再传一次,就可能产生重复消息。当前还没有通过稳定的上传请求标识解决这个问题。
初版面向局域网临时中转,先处理流式上传、失败清理和整批重试。对于不稳定网络下的大文件传输,续传和幂等还需要继续补。
旧版直接启动 server.js,监听 3000 端口,把当前局域网地址打印出来,浏览器访问即可。
新版使用 Docker Compose,分别运行 Node.js 和 Caddy。应用镜像的 Dockerfile 如下:
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
COPY lib lib
COPY routes routes
COPY public public
USER node
EXPOSE 3000
CMD ["node", "server.js"]
依赖文件先复制,业务代码后复制。只修改业务代码时,可以复用依赖安装层。
应用数据通过绑定挂载保存在宿主机:
volumes:
- ./data:/app/data
容器中的 /app/data 对应宿主机目录,替换应用容器时不需要把消息和文件一起搬走。Docker 绑定挂载文档
Caddy 的证书状态另外保存在命名卷中,恢复整个部署环境时也需要考虑这部分数据。
旧版直接从工作目录返回 index.html。现在前端文件通过 Dockerfile 的 COPY 放进镜像,宿主机上的文件修改不会自动进入已有镜像。
所以更新代码后,需要重新构建并启动:
docker compose build
docker compose up -d
只重启旧容器,里面仍然是旧代码。
当前 Node.js 的宿主机调试端口只绑定回环地址:
ports:
- "127.0.0.1:13000:3000"
局域网其他设备统一访问 Caddy 的 HTTPS 入口,避免另外留下一个可以直接访问的明文端口。
旧版启动时遍历网卡,找到第一个非回环 IPv4,再打印访问地址。每次启动能知道当前地址,但已经保存到手机和其他电脑上的旧地址不会自动更新。
尤其页面添加到手机主屏幕后,入口还指向旧 IP,地址一变就需要重新处理。
能配置路由器的话,可以考虑 DHCP 地址保留。当时的租房网络不方便改路由器,所以这里改成固定域名,再用脚本更新域名对应的局域网 IP。
以下地址只是示例:
board.example.com → 192.168.1.20
服务器地址变成 192.168.1.35 后,更新 A 记录,各台设备仍然访问同一个域名。
脚本获取本机 IPv4,查询阿里云 DNS 中的记录,再决定是否新增或修改:
function decideAction(lanIp, records) {
const aRecords = (records || []).filter(r => r.Type === 'A');
if (aRecords.length === 0) {
return { op: 'add' };
}
const record = aRecords[0];
return record.Value === lanIp
? { op: 'none' }
: { op: 'update', recordId: record.RecordId };
}
Windows 任务计划程序每小时执行一次。地址相同就不操作,发生变化再更新。
这能减少逐台修改入口的麻烦,但恢复不是即时的,仍然受到检查周期和 DNS 缓存影响。
网卡选择也还比较简单,只对候选地址进行优先级排序。如果机器同时连着 VPN、虚拟网卡或者多个局域网,需要进一步明确应该选哪个地址。
这里更新的是私网 IP,没有建立内网穿透,也没有自动配置路由器端口转发。手机切到流量以后,不会因为有域名就能访问宿舍里的服务。
固定域名还让浏览器的 origin 保持不变。底层 IP 可以调整,但协议、主机名和端口不变,浏览器里的本地设置和站点权限就不需要跟着换地址。
HTTPS 由 Caddy 处理,证书验证采用 DNS-01。
DNS-01 通过添加指定的 TXT 记录证明域名控制权。证书签发机构检查的是 DNS 记录,不要求从公网连接到这个内网 Web 服务。Let’s Encrypt DNS-01 说明
Caddy 需要调用 DNS 服务商 API 来添加和清理验证记录,因此项目使用了包含阿里云 DNS 插件的自定义镜像。Caddy DNS challenge 文档
配置如下,域名已替换为示例:
board.example.com {
tls {
dns alidns {
access_key_id {$ALI_AK}
access_key_secret {$ALI_AS}
}
}
reverse_proxy app:3000
}
这里有两种不同的 DNS 操作:
| 操作 | 记录类型 | 用途 |
|---|---|---|
| DDNS | A | 更新客户端访问的服务器地址 |
| DNS-01 | TXT | 验证域名控制权,申请证书 |
IP 变化后,证书对应的域名没有变,后续仍然由 Caddy 管理证书。
文件本体由自己的服务器保存,但 DNS 更新、证书申请和续期需要外部服务。后面加入的离线推送也涉及外部推送服务,所以整个部署不能笼统地说成完全不依赖外网。
容器部署后遇到过一次比较难直接判断的故障。
当时容器都在运行,Caddy 日志没有明显异常,DNS 和证书也正常,但浏览器无法访问。
进一步检查发现,容器内部请求可以正常返回;宿主机请求映射端口时,TCP 能连接,HTTPS 却在握手阶段失败,HTTP 则返回空响应。
需要把请求经过的位置拆开看:
应用进程
↓
容器内部网络
↓
Docker Desktop / WSL2 转发
↓
Windows 宿主机
↓
其他局域网设备
容器内能访问,宿主机通过映射端口访问失败,问题范围就缩小到了中间的转发路径。
这次最终通过退出 Docker Desktop、关闭 WSL 后端,再重新启动恢复。
这个处理针对的是当时那次故障。wsl --shutdown 会影响其他正在运行的 WSL 环境,不能看到连接失败就直接执行。先比较容器内、宿主机和其他设备的请求结果,再决定处理哪一层。
只确认端口能建立 TCP 连接还不够,还要继续确认 HTTP 请求能否到达应用,以及响应能否返回。
旧版脚本和样式大部分写在 HTML 中。新版拆出独立资源,又增加了手机主屏幕入口,部署后还需要处理客户端拿到旧资源的问题。
先确认镜像确实重新构建,再看浏览器缓存。
当前服务在启动时计算前端文件的内容指纹:
const hashPublic = (rel) => crypto
.createHash('sha256')
.update(fs.readFileSync(path.join(config.publicDir, rel)))
.digest('base64url')
.slice(0, 10);
然后把指纹注入 HTML 中,下面是生成结果的示意:
<script src="/app.js?v=内容指纹"></script>
<link rel="stylesheet" href="/style.css?v=内容指纹">
文件内容变化,资源 URL 就跟着变化。
但只给资源加指纹还不够。如果浏览器复用旧 HTML,里面仍然引用旧 URL,新的指纹根本不会出现在客户端。
所以入口 HTML 同时设置:
Cache-Control: no-cache
no-cache 允许缓存响应,但复用前需要向服务端验证;no-store 才是不保存响应。HTTP 缓存说明
这样重新加载页面时,先确认入口 HTML,再加载它引用的资源。已经打开的页面仍然需要刷新,不会因为服务端完成部署就替换正在执行的 JavaScript。
项目中的 Service Worker 只负责通知,没有实现 fetch 缓存,所以不需要再维护另一套静态资源缓存更新逻辑。
目前这个版本已经用来在几台设备之间中转文字和文件,默认保留七天,之前传过的内容可以翻历史记录或者搜索。服务端保持运行,各台设备打开同一个地址就能用。上传续传和幂等还没有做,大文件中断后仍然需要重新上传;数据存储也仍然按单实例使用。