termchat: 极简双人终端聊天 (Node + TypeScript + Ink)
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
Generated
+1159
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user