一个看起来像终端日志、用起来像即时通讯的跨网络聊天工具

为什么需要这个?
假设你在一家公司上班,公司网络会对即时通讯软件(微信、钉钉、Telegram)进行监控或限制,但你仍然需要与同事进行实时沟通。又或者,你想和朋友在不受限的环境下聊天,但又不想被防火墙识别为“聊天流量”。

本文会教你从零搭建一个完全伪装成终端日志的实时聊天器,特点如下:

✅ 跨网络通信(基于 WebSocket + ngrok 内网穿透)

✅ 界面伪装成终端/代码编辑器,看不出是聊天工具

✅ 支持暗色/亮色主题切换

✅ 历史消息自动保存,离线后重新上线可同步

✅ 实时感知对方上下线状态

✅ 一键导出完整聊天记录(.log 文件)

最终效果图(暗色主题):

text
┌─────────────────────────────────────────────────────────────┐
│ ● ● ● ~/dev/console ● online │
├─────────────────────────────────────────────────────────────┤
│ const log = (msg) => console.log(msg); │
│ let data = []; │
│ function init() { /* … */ } │
│ ═══════════════════════════════════════════════════════ │
│ system start · log stream │
│ [10:00:01] S initializing config module │
│ [10:00:02] S loading local cache data │
│ [10:00:03] S establishing WebSocket connection │
│ [10:00:04] S connection successful, handshake done │
│ ───────────────────────────────────────────────────────── │
│ real-time output started │
│ [14:51:55] D hello, anyone there? │
│ [14:51:57] A yes, I’m here! │
│ $ _ │
├─────────────────────────────────────────────────────────────┤
│ [service] [user] [connect] [export] │
└─────────────────────────────────────────────────────────────┘
你看,这完全就是一个终端开发日志的样子,谁会想到它是个聊天室呢?

技术栈概览
层级 技术 作用
前端 原生 HTML + CSS + JavaScript 伪装界面、WebSocket 客户端、主题切换
后端 Node.js + ws 库 WebSocket 服务器、消息广播、历史存储
穿透 ngrok (HTTP 隧道) 将本地 3000 端口暴露到公网
通信协议 WebSocket (wss://) 全双工实时通信
第一步:服务端搭建
1.1 安装依赖
bash
mkdir chat-server
cd chat-server
npm init -y
npm install ws
1.2 服务端完整代码 (server.js)
这是整个聊天器的核心,负责:

WebSocket 连接管理

消息广播(排除发送者)

历史消息存储(内存)

上下线事件广播

历史记录导出(HTTP /export 接口)

javascript
const WebSocket = require(‘ws’);
const http = require(‘http’);

// ---------- 内存存储 ----------
const messageHistory = [];
const userMap = new Map(); // ws -> username

// ---------- HTTP 导出路由 ----------
const server = http.createServer((req, res) => {
if (req.url === ‘/export’) {
let logContent = ‘’;
if (messageHistory.length === 0) {
logContent = ‘No chat history yet.’;
} else {
messageHistory.forEach(msg => {
logContent += [${msg.time}] [${msg.sender}] ${msg.content}\n;
});
}
res.writeHead(200, {
‘Content-Type’: ‘text/plain; charset=utf-8’,
‘Content-Disposition’: ‘attachment; filename=“chat_history.log”’
});
res.end(logContent);
return;
}
res.writeHead(404);
res.end(‘Not Found’);
});

// ---------- WebSocket 服务器 ----------
const wss = new WebSocket.Server({ server });

console.log(‘✅ 服务器已启动,监听 0.0.0.0:3000’);
console.log(‘📄 导出地址: http://localhost:3000/export’);

wss.on(‘connection’, (ws) => {
console.log(‘🔗 新用户连接,当前在线:’, wss.clients.size);

// ⭐ 新连接自动同步历史消息
messageHistory.forEach(msg => {
    ws.send(JSON.stringify({
        type: 'message',
        sender: msg.sender,
        content: msg.content,
        time: msg.time
    }));
});

ws.on('message', (message) => {
    const raw = message.toString();
    console.log('📩 收到:', raw);

    let data;
    try {
        data = JSON.parse(raw);
    } catch (_) {
        const fakeMsg = { type: 'message', sender: 'System', content: raw, time: new Date().toLocaleTimeString() };
        messageHistory.push(fakeMsg);
        broadcast(raw, ws);
        return;
    }

    // 处理 join 事件(用户上线)
    if (data.type === 'join') {
        const username = data.sender || '匿名';
        userMap.set(ws, username);
        const joinMsg = {
            type: 'system',
            content: `✨ ${username} joined`,
            time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
        };
        messageHistory.push(joinMsg);
        broadcast(JSON.stringify(joinMsg), ws);
        return;
    }

    // 处理心跳(防止自动断联)
    if (data.type === 'ping') {
        ws.send(JSON.stringify({ type: 'pong' }));
        return;
    }

    // 存储并广播普通消息
    const storeMsg = {
        time: data.time || new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
        sender: data.sender || '匿名',
        content: data.content || ''
    };
    messageHistory.push(storeMsg);
    broadcast(raw, ws);
});

ws.on('close', () => {
    const username = userMap.get(ws);
    if (username) {
        const leaveMsg = {
            type: 'system',
            content: `👋 ${username} left`,
            time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
        };
        messageHistory.push(leaveMsg);
        broadcast(JSON.stringify(leaveMsg), ws);
        userMap.delete(ws);
    }
    console.log('🔌 用户断开,剩余:', wss.clients.size);
});

});

function broadcast(data, sender) {
wss.clients.forEach((client) => {
if (client !== sender && client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
}

server.listen(3000, ‘0.0.0.0’, () => {
console.log(‘✅ HTTP & WebSocket 服务器已就绪’);
});
1.3 启动服务
bash
node server.js
看到以下输出即表示成功:

text
✅ 服务器已启动,监听 0.0.0.0:3000
📄 导出地址: http://localhost:3000/export
✅ HTTP & WebSocket 服务器已就绪
第二步:前端界面(终端伪装)
前端是整个项目的“灵魂”——它把聊天界面伪装成终端日志。

2.1 核心设计理念
界面元素 伪装效果
页面标题 ~/dev/console(终端路径)
消息格式 [时间] 首字母 消息内容
自己的消息 首字母高亮为黄色
他人的消息 首字母为绿色
系统消息 居中灰色,带上下分割线
输入框 提示 enter command…
发送按钮 文字为 run
四周装饰 静态 JavaScript 代码片段
2.2 主题系统
通过 CSS 变量实现暗色/亮色主题切换,用户点击工具栏的 🌓 按钮即可切换,偏好保存在 localStorage 中。

css
:root {
/* 暗色主题(默认) /
–bg-body: #0c0f14;
–bg-workspace: #14181e;
–text-terminal: #b8d0e0;
/
… 更多变量 */
}

body.light-theme {
/* 亮色主题覆盖 /
–bg-body: #f5f7fa;
–bg-workspace: #ffffff;
–text-terminal: #1e293b;
/
… 更多变量 */
}
第三步:内网穿透(ngrok)
要让不同网络下的朋友连接,我们需要把本地 3000 端口暴露到公网。

3.1 安装 ngrok
bash

下载 ngrok (Windows/Mac/Linux)

访问 https://ngrok.com/download

解压后,在命令行执行:

ngrok config add-authtoken <你的token>
3.2 启动隧道
注意:因为 WebSocket 基于 HTTP 升级,要用 http 隧道,不是 tcp。

bash
ngrok http 3000
看到输出:

text
Forwarding https://xxxx.ngrok-free.dev -> http://localhost:3000
复制 https://xxxx.ngrok-free.dev 地址。

3.3 前端连接地址
在页面底部的 service 输入框中填写:

text
wss://xxxx.ngrok-free.dev
⚠️ 关键:https:// → wss://,不要加端口号。

第四步:核心功能详解
4.1 消息格式(终端风格)
每条消息以一行显示,格式为:

text
[时间] [首字母] 消息内容
时间:[14:51:55] 灰色小字,置于行末

首字母:用户名的第一个字符,绿色(自己为黄色)

内容:纯文本,等宽字体

代码实现:

javascript
function appendLog(text, isSystem = false, isSelf = false) {
const div = document.createElement(‘div’);
if (isSystem) {
div.className = ‘system-line’;
div.textContent = text;
} else {
div.className = log-line ${isSelf ? 'self' : ''};
const now = new Date().toLocaleTimeString(‘zh-CN’, { hour: ‘2-digit’, minute: ‘2-digit’, second: ‘2-digit’ });
const initial = myName.charAt(0).toUpperCase();
div.innerHTML = <span class="initial">${initial}</span> <span class="content">${text}</span> <span class="timestamp">[${now}]</span> ;
}
terminal.appendChild(div);
div.scrollIntoView({ behavior: ‘smooth’, block: ‘nearest’ });
}
4.2 历史记录与离线消息
服务端用 messageHistory 数组存储所有消息(包括系统消息)。当新客户端连接时,自动逐条推送全部历史记录。

关键代码:

javascript
wss.on(‘connection’, (ws) => {
// 新连接自动同步历史消息
messageHistory.forEach(msg => {
ws.send(JSON.stringify({
type: ‘message’,
sender: msg.sender,
content: msg.content,
time: msg.time
}));
});
// …
});
4.3 上下线感知
客户端连接时发送 { type: ‘join’, sender: username }

服务端广播 ✨ 用户名 joined

客户端断开时广播 👋 用户名 left

4.4 导出聊天记录
两种方式:

点击 export 按钮:下载 chat_history.log

输入 /export 命令:效果相同

导出格式为纯文本:

text
[14:51:55] [dev] hello
[14:51:57] [alice] hi there
[14:52:00] ✨ bob joined
第五步:常见问题与踩坑记录
5.1 ngrok TCP 隧道报错 “credit card required”
问题:运行 ngrok tcp 3000 提示需要绑定信用卡。

原因:ngrok 免费账户的 TCP 隧道需要验证支付方式。

解决:改用 ngrok http 3000(WebSocket 基于 HTTP 升级,可以工作)。

5.2 连接失败 502 / ERR_NGROK_8012
原因:Node.js 服务未启动、端口被占用、防火墙拦截。

解决:

确认 node server.js 正在运行

检查端口 3000 是否被占用:netstat -ano | findstr :3000

临时关闭 Windows 防火墙测试

5.3 消息重复
原因:前端回显 + 服务端广播给自己。

解决:服务端广播时排除发送者(client !== sender)。

5.4 自动断联
原因:ngrok 或浏览器空闲超时。

解决:前端每 15 秒发送一次 ping,服务端回复 pong。

5.5 导出报错 “Upgrade Required”
原因:前端用 http:// 请求,但页面通过 https:// 加载,浏览器阻止混合内容。

解决:使用 window.location.origin + ‘/export’ 保证同源请求。

第六步:部署与分享
6.1 本地部署
将 server.js 放在服务器目录

运行 node server.js

用浏览器打开 index.html

6.2 公网分享
启动 ngrok:ngrok http 3000

复制 https://xxxx.ngrok-free.dev

告诉朋友在页面地址框填写:wss://xxxx.ngrok-free.dev

朋友打开 index.html,填入地址,点击 connect

6.3 多人聊天
服务端使用 broadcast() 将消息发送给所有已连接客户端,因此支持无限多人同时在线(ngrok 免费版有并发限制,约 40 连接/分钟)。

拓展与优化方向
方向 实现思路
持久化存储 将 messageHistory 写入文件或 SQLite
消息加密 添加 crypto 库进行端到端加密
图片/文件传输 将文件转为 Base64 或使用 WebRTC
多房间 增加 room 字段,按房间隔离消息
命令行模式 支持 /nick 改名、/help 等更多命令
完整代码获取
所有代码已在文章中完整展示,你也可以直接复制使用:

服务端:server.js(约 100 行)

前端:index.html(约 400 行)

两个文件即可运行,无需其他依赖。

结语
这个项目展示了如何用 WebSocket + 终端伪装 实现一个“不被发现”的实时聊天工具。它的核心价值不在于技术复杂度,而在于对抗审查的创意——把聊天流量伪装成正常的开发活动。

当然,技术的意义在于连接人与人,而不是规避规则。希望这篇文章能帮助你理解 WebSocket 的实战应用,以及如何用简单的代码实现一个“有尊严”的通信工具

更多推荐