termchat: 极简双人终端聊天 (Node + TypeScript + Ink)

This commit is contained in:
2026-09-30 15:27:06 +08:00
commit c6b52e2574
9 changed files with 1541 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+47
View File
@@ -0,0 +1,47 @@
# termchat — 极简双人终端聊天
一个命令启动,两个人直连聊天。无用户系统、无历史存储,断线即散。
## 用法
```bash
npm run build
# A 机(服务端)
node dist/cli.js serve -n 小明
# B 机(客户端,填 A 的 IP;本机测试用 127.0.0.1)
node dist/cli.js connect <A的IP> -n 小红
```
装成全局命令:
```bash
npm install -g .
chat serve -n 小明
chat connect 192.168.x.x -n 小红
```
开发模式(免编译):`npm run dev serve -n 小明`
## 参数
| 命令 | 选项 | 默认 |
|---|---|---|
| `serve` | `-p, --port` | 9000 |
| `serve` | `-n, --name` | A |
| `connect <host>` | `-p, --port` | 9000 |
| `connect <host>` | `-n, --name` | B |
## 说明
- 协议:TCP,每行一个 JSON(`{"type":"msg","name":"…","text":"…","ts":…}`)
- 服务端只接受第一个连入的客户端
- 局域网直连;跨公网需要 A 侧有公网 IP 或端口映射(或后续加中转服务器)
- Windows 首次 `serve` 会弹防火墙授权,允许即可
## 测试
```bash
npm run smoke # 通信层冒烟测试(不起 UI)
```
+1159
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "termchat",
"version": "0.1.0",
"description": "极简双人终端聊天",
"type": "module",
"bin": {
"chat": "./dist/cli.js"
},
"scripts": {
"dev": "tsx src/cli.ts",
"build": "tsc",
"start": "node dist/cli.js",
"smoke": "tsx scripts/smoke.ts"
},
"dependencies": {
"commander": "^13.0.0",
"ink": "^5.0.1",
"react": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^18.3.0",
"tsx": "^4.19.0",
"typescript": "^5.6.3"
}
}
+35
View File
@@ -0,0 +1,35 @@
/** 不依赖 UI 的通信层冒烟测试:本机起 server + client,验证双向收发 */
import { createSession, type ChatMessage } from "../src/net.js";
const port = 19321;
const aGot: ChatMessage[] = [];
const bGot: ChatMessage[] = [];
const a = createSession("server", { port, name: "A" }, {
onMessage: (m) => aGot.push(m),
onPhase: () => {},
});
const b = createSession("client", { host: "127.0.0.1", port, name: "B" }, {
onMessage: (m) => bGot.push(m),
onPhase: () => {},
});
setTimeout(() => {
b.send("你好,A!");
a.send("收到,B");
}, 300);
setTimeout(() => {
const texts = (arr: ChatMessage[]) =>
arr.filter((m) => m.type === "msg").map((m) => `${m.name}:${m.text}`);
const aMsgs = texts(aGot);
const bMsgs = texts(bGot);
console.log("A 收到:", aMsgs);
console.log("B 收到:", bMsgs);
const ok = aMsgs.includes("B:你好,A!") && bMsgs.includes("A:收到,B");
console.log(ok ? "✅ 双向通信正常" : "❌ 测试失败");
a.close();
b.close();
process.exit(ok ? 0 : 1);
}, 1200);
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
import { Command } from "commander";
import { runUI } from "./ui.js";
const program = new Command();
program
.name("chat")
.description("极简双人终端聊天")
.version("0.1.0");
program
.command("serve")
.description("作为服务端,等待对方连接")
.option("-p, --port <number>", "监听端口", "9000")
.option("-n, --name <name>", "你的昵称", "A")
.action((opts) => {
const port = Number(opts.port);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
console.error(`无效端口:${opts.port}`);
process.exit(1);
}
runUI({ role: "server", port, name: opts.name });
});
program
.command("connect <host>")
.description("连接到对方")
.option("-p, --port <number>", "端口", "9000")
.option("-n, --name <name>", "你的昵称", "B")
.action((host, opts) => {
const port = Number(opts.port);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
console.error(`无效端口:${opts.port}`);
process.exit(1);
}
runUI({ role: "client", host, port, name: opts.name });
});
program.parse();
+116
View File
@@ -0,0 +1,116 @@
import * as net from "node:net";
import { createInterface } from "node:readline";
/** 消息:msg 为聊天消息,sys 为系统提示(仅本地显示,不走网络) */
export interface ChatMessage {
type: "msg" | "sys";
/** 发送方昵称,sys 消息为空 */
name: string;
text: string;
ts: number;
}
export type Phase = "waiting" | "connected" | "closed";
export interface SessionHandlers {
onMessage(m: ChatMessage): void;
onPhase(phase: Phase): void;
}
export interface SessionOptions {
host?: string;
port: number;
name: string;
}
export interface Session {
send(text: string): void;
close(): void;
readonly phase: Phase;
}
/**
* 建立一条双人会话:
* - server: 监听端口,等对方连入(只接受第一个连接)
* - client: 主动连接对方
* 协议:每行一个 JSON
*/
export function createSession(
role: "server" | "client",
opts: SessionOptions,
handlers: SessionHandlers
): Session {
let socket: net.Socket | null = null;
let server: net.Server | null = null;
let phase: Phase = "waiting";
const queue: string[] = [];
const sys = (text: string): void =>
handlers.onMessage({ type: "sys", name: "", text, ts: Date.now() });
const setPhase = (p: Phase): void => {
if (phase === p) return;
phase = p;
handlers.onPhase(p);
if (p === "connected") {
for (const payload of queue.splice(0)) socket?.write(payload);
}
};
const wire = (sock: net.Socket): void => {
socket = sock;
const rl = createInterface({ input: sock });
rl.on("line", (line) => {
const s = line.trim();
if (!s) return;
try {
handlers.onMessage(JSON.parse(s) as ChatMessage);
} catch {
// 兜底:对方发来的不是 JSON 就当纯文本
handlers.onMessage({ type: "msg", name: "对方", text: s, ts: Date.now() });
}
});
sock.on("error", (err) => sys(`连接出错:${err.message}`));
sock.on("close", () => setPhase("closed"));
};
if (role === "server") {
server = net.createServer((sock) => {
server?.close(); // 只接受第一个连入的人
wire(sock);
setPhase("connected");
sys("对方已连入,开始聊天吧");
});
server.on("error", (err) => {
sys(`监听失败:${err.message}`);
setPhase("closed");
});
server.listen(opts.port, () => {
sys(`等待对方连接 0.0.0.0:${opts.port} …`);
});
} else {
const sock = net.connect({ host: opts.host ?? "127.0.0.1", port: opts.port });
wire(sock);
sock.on("connect", () => {
setPhase("connected");
sys(`已连接 ${opts.host}:${opts.port}`);
});
}
return {
send(text: string): void {
const payload =
JSON.stringify({ type: "msg", name: opts.name, text, ts: Date.now() }) + "\n";
if (phase === "connected") socket?.write(payload);
else if (phase === "waiting") queue.push(payload);
},
close(): void {
server?.close();
socket?.end();
setPhase("closed");
},
get phase(): Phase {
return phase;
},
};
}
+102
View File
@@ -0,0 +1,102 @@
import React, { useEffect, useRef, useState } from "react";
import { Box, Static, Text, render, useInput } from "ink";
import { createSession, type ChatMessage, type Phase, type Session } from "./net.js";
export interface UIOptions {
role: "server" | "client";
host?: string;
port: number;
name: string;
}
export function runUI(opts: UIOptions): void {
render(<App {...opts} />);
}
function App({ role, host, port, name }: UIOptions) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [phase, setPhase] = useState<Phase>("waiting");
const sessionRef = useRef<Session | null>(null);
useEffect(() => {
const session = createSession(
role,
{ host, port, name },
{
onMessage: (m) => setMessages((prev) => [...prev, m]),
onPhase: (p) => setPhase(p),
}
);
sessionRef.current = session;
return () => session.close();
}, []);
useInput((ch, key) => {
if (key.return) {
const text = input.trim();
if (!text || phase !== "connected") return;
sessionRef.current?.send(text);
setMessages((prev) => [
...prev,
{ type: "msg", name, text, ts: Date.now() },
]);
setInput("");
} else if (key.backspace || key.delete) {
setInput((prev) => prev.slice(0, -1));
} else if (ch && !key.ctrl && !key.meta && !key.escape) {
setInput((prev) => prev + ch);
}
});
const statusText =
phase === "connected"
? `已连接 · 你是「${name}」`
: phase === "waiting"
? role === "server"
? `等待对方连接 0.0.0.0:${port} …`
: `正在连接 ${host}:${port} …`
: "连接已断开,按 Ctrl+C 退出";
const borderColor =
phase === "connected" ? "green" : phase === "waiting" ? "yellow" : "red";
return (
<>
<Static items={messages}>
{(m, i) => <Message key={`${m.ts}-${i}`} m={m} me={name} />}
</Static>
<Box borderStyle="round" borderColor={borderColor} paddingLeft={1} paddingRight={1}>
<Text dimColor>{statusText}</Text>
</Box>
<Box>
<Text bold color="green">
❯{" "}
</Text>
<Text>{input}</Text>
<Text color="green">▌</Text>
</Box>
</>
);
}
function Message({ m, me }: { m: ChatMessage; me: string }) {
if (m.type === "sys") {
return (
<Text dimColor color="yellow">
· {m.text}
</Text>
);
}
const mine = m.name === me;
const time = new Date(m.ts).toLocaleTimeString("zh-CN", { hour12: false });
return (
<Text>
<Text dimColor>{time} </Text>
<Text bold color={mine ? "green" : "cyan"}>
{mine ? "我" : m.name}:{" "}
</Text>
<Text>{m.text}</Text>
</Text>
);
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}