OYLZ
博客游戏其他OY_UIPixelForge一池
备案信息: 粤ICP备18091077号-1

用 Node.js 做一个局域网共享中转板

2026/6/2 15:29:0
Nodejs ,局域网共享工具

宿舍里有两台台式机、一台 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 广播新条目的信息。其他设备先更新消息流,需要预览或下载时,再请求文件接口。

这样一台电脑上传文件,不会把整个文件内容主动推给所有在线设备。手机只想看看文件名,就不需要先接收完整附件。

文字消息仍然走 Socket.IO。文件上传和实时通知分开后,两边可以分别处理大小限制、上传进度和消息同步。

三、文件保存下来之后,还需要保存它的信息

旧版直接用原始文件名作为磁盘路径:

const fileName = `uploads/${fileData.fileName}`;

如果两次都上传 截图.png,后一次会写入同一个路径,覆盖前一次的文件。消息列表里虽然有两条记录,链接却指向同一个位置。

现在每个文件有独立 ID,磁盘文件名组合成:

<文件 ID>__<处理后的原文件名>

原文件名用于展示,文件 ID 用于寻址。这样两台设备上传同名文件,也可以分别保存。

下载时,客户端通过文件 ID 请求接口,服务端在当前空间的记录中查找,再定位磁盘文件,不直接把用户提供的文件名当成访问路径。

从 HTML 消息改成结构化条目

旧版文件上传完成后,服务端会拼一段 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 进程使用,否则两边各持有一份内存状态,后保存的一方可能覆盖前一方的修改。

五、上传失败后,哪些东西需要清理

旧版文件处理主要就是写入、创建消息、广播,没有把失败过程单独组织起来。

新版加入批量上传后,一次操作会同时影响文件、消息条目和容量统计。假设一批有五个文件,前四个写完,第五个失败,就需要决定这批内容是否还能出现在消息流里。

当前以整个批次为一条消息处理:

  1. 接收文件,等待本批写入结束。
  2. 检查解析错误、大小限制和写入错误。
  3. 创建消息条目,更新容量统计,调整文件名。
  4. 完成容量检查后,广播消息并返回结果。

前面的检查失败,就清理临时文件。后面的步骤出错,则尝试撤销已创建的条目、删除文件,并扣回用量。

文件和 JSON 元数据没有放在同一个事务中,这里做的是应用层的一致性处理和尽力回滚。

请求读完,不代表上传操作已经结束

请求体读完后,服务端可能还在等待磁盘写入,也可能正在处理消息入账。此时不能简单根据请求流结束,就判断客户端取消或者操作成功。

项目通过响应的关闭事件辅助判断连接是否提前结束:

let clientGone = false;

res.on('close', () => {
  if (!res.writableEnded) {
    clientGone = true;
  }
});

如果响应还没结束,连接就关闭了,先记录状态。后续执行到提交前的检查点,发现客户端已经断开,就清理临时文件,不再创建消息。

请求流、文件写入流和响应有各自的生命周期,这几个结束时机需要分开处理。

当前的重试还不是断点续传

前端上传失败后,会自动重试一次。如果仍然失败,就把该批次保留在失败列表里,用户可以手动重试。主动取消则不自动重传。

这里会重新发送整批文件,没有复用上一次已经上传的字节。

下载接口使用 res.sendFile(),当前依赖中的实现可以处理 Range 请求。但下载端能够返回指定范围的文件内容,不代表上传端也实现了续传。

真正做上传续传,还需要记录上传会话、已收到的偏移量或分片,并处理校验、最终提交和过期清理。客户端恢复时先查询服务端进度,再发送剩余内容。

重试还涉及幂等问题:服务端可能已经提交成功,只是响应没能返回客户端。客户端再传一次,就可能产生重复消息。当前还没有通过稳定的上传请求标识解决这个问题。

初版面向局域网临时中转,先处理流式上传、失败清理和整批重试。对于不稳定网络下的大文件传输,续传和幂等还需要继续补。

六、从直接运行 Node.js 改成 Docker 部署

旧版直接启动 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 入口,避免另外留下一个可以直接访问的明文端口。

七、租房网络下,服务器 IP 会变化

旧版启动时遍历网卡,找到第一个非回环 IPv4,再打印访问地址。每次启动能知道当前地址,但已经保存到手机和其他电脑上的旧地址不会自动更新。

尤其页面添加到手机主屏幕后,入口还指向旧 IP,地址一变就需要重新处理。

能配置路由器的话,可以考虑 DHCP 地址保留。当时的租房网络不方便改路由器,所以这里改成固定域名,再用脚本更新域名对应的局域网 IP。

以下地址只是示例:

board.example.com → 192.168.1.20

服务器地址变成 192.168.1.35 后,更新 A 记录,各台设备仍然访问同一个域名。

DDNS 脚本负责更新 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 可以调整,但协议、主机名和端口不变,浏览器里的本地设置和站点权限就不需要跟着换地址。

用 DNS-01 给内网服务申请证书

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 操作:

操作记录类型用途
DDNSA更新客户端访问的服务器地址
DNS-01TXT验证域名控制权,申请证书

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 缓存,所以不需要再维护另一套静态资源缓存更新逻辑。

目前这个版本已经用来在几台设备之间中转文字和文件,默认保留七天,之前传过的内容可以翻历史记录或者搜索。服务端保持运行,各台设备打开同一个地址就能用。上传续传和幂等还没有做,大文件中断后仍然需要重新上传;数据存储也仍然按单实例使用。