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