ephem-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +530 -0
- package/dist/index.js.map +1 -0
- package/integration-test.mjs +226 -0
- package/package.json +32 -0
- package/src/crypto/cipher.ts +43 -0
- package/src/crypto/deriveKey.ts +21 -0
- package/src/index.ts +37 -0
- package/src/ui/App.tsx +129 -0
- package/src/ui/ChatRoom.tsx +194 -0
- package/src/ui/SetupWizard.tsx +82 -0
- package/src/ws/client.ts +182 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
|
|
5
|
+
export interface ConnectConfig {
|
|
6
|
+
server: string;
|
|
7
|
+
room: string;
|
|
8
|
+
username: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
defaults: { server?: string; room?: string; username?: string };
|
|
13
|
+
onComplete: (cfg: ConnectConfig) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const STEPS = ["后端地址", "房间码", "用户名"] as const;
|
|
17
|
+
|
|
18
|
+
/** 三步问答:后端地址 → 房间码 → 用户名。完成后回调 onComplete。 */
|
|
19
|
+
export function SetupWizard({ defaults, onComplete }: Props) {
|
|
20
|
+
const [step, setStep] = useState(0);
|
|
21
|
+
const [server, setServer] = useState(defaults.server ?? "");
|
|
22
|
+
const [room, setRoom] = useState((defaults.room ?? "").toLowerCase());
|
|
23
|
+
const [username, setUsername] = useState(defaults.username ?? "");
|
|
24
|
+
|
|
25
|
+
const values = [server, room, username];
|
|
26
|
+
const setters = [setServer, setRoom, setUsername];
|
|
27
|
+
|
|
28
|
+
function submit(value: string) {
|
|
29
|
+
const v = value.trim();
|
|
30
|
+
setters[step](v);
|
|
31
|
+
if (step === 0 && !v) {
|
|
32
|
+
// 后端地址为空时拒绝(除非有默认值)
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (step < 2) {
|
|
36
|
+
setStep(step + 1);
|
|
37
|
+
} else {
|
|
38
|
+
onComplete({
|
|
39
|
+
server: (server || "").trim(),
|
|
40
|
+
room: (room || "").trim().toLowerCase(),
|
|
41
|
+
username: (v || "匿名").slice(0, 32),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Box flexDirection="column" gap={1}>
|
|
48
|
+
<Box flexDirection="column">
|
|
49
|
+
<Text color="cyan" bold>
|
|
50
|
+
ephem · 临时加密聊天室
|
|
51
|
+
</Text>
|
|
52
|
+
<Text color="gray">按回车进入下一步,Ctrl+C 退出</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
|
|
55
|
+
{STEPS.map((label, i) => {
|
|
56
|
+
const done = i < step;
|
|
57
|
+
const active = i === step;
|
|
58
|
+
return (
|
|
59
|
+
<Box key={label} flexDirection="column">
|
|
60
|
+
<Text color={active ? "cyan" : "gray"}>
|
|
61
|
+
{done ? "✓" : active ? "?" : "·"} {label}
|
|
62
|
+
{i === 0 && defaults.server ? "(回车使用默认值)" : ""}
|
|
63
|
+
</Text>
|
|
64
|
+
{active ? (
|
|
65
|
+
<Box>
|
|
66
|
+
<Text color="gray"> › </Text>
|
|
67
|
+
<TextInput
|
|
68
|
+
value={values[i]}
|
|
69
|
+
onChange={(v) => setters[i](v)}
|
|
70
|
+
onSubmit={submit}
|
|
71
|
+
placeholder={i === 0 ? "wss://your-worker.workers.dev" : i === 1 ? "correct-horse-battery" : "你的名字"}
|
|
72
|
+
/>
|
|
73
|
+
</Box>
|
|
74
|
+
) : done ? (
|
|
75
|
+
<Text color="gray"> {values[i] || "(空)"}</Text>
|
|
76
|
+
) : null}
|
|
77
|
+
</Box>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</Box>
|
|
81
|
+
);
|
|
82
|
+
}
|
package/src/ws/client.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// 房间 WebSocket 客户端:连接、收发密文、心跳保活、断线指数退避重连。
|
|
2
|
+
// 服务端主动拒绝(房间不存在/已满/过期)时不重连,交由 UI 处理。
|
|
3
|
+
|
|
4
|
+
import { EventEmitter } from "node:events";
|
|
5
|
+
import WebSocket from "ws";
|
|
6
|
+
|
|
7
|
+
export interface JoinedInfo {
|
|
8
|
+
username: string;
|
|
9
|
+
currentMembers: number;
|
|
10
|
+
maxMembers: number;
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ChatMessage {
|
|
15
|
+
from: string;
|
|
16
|
+
ciphertext: string;
|
|
17
|
+
nonce: string;
|
|
18
|
+
timestamp: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CloseReason = "ttl_expired" | "empty" | "manual";
|
|
22
|
+
|
|
23
|
+
interface ReconnectingInfo {
|
|
24
|
+
attempt: number;
|
|
25
|
+
delayMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 事件(全部通过 on 订阅):
|
|
30
|
+
* joined(info) 加入成功
|
|
31
|
+
* peer_joined({username})
|
|
32
|
+
* peer_left({username})
|
|
33
|
+
* message(msg) 收到一条密文消息
|
|
34
|
+
* room_closing({reason})房间即将销毁
|
|
35
|
+
* server_error({code,message}) 服务端拒绝/出错(不可恢复)
|
|
36
|
+
* reconnecting(info) 断线后准备第 N 次重连
|
|
37
|
+
* closed() 连接彻底关闭
|
|
38
|
+
*/
|
|
39
|
+
export class RoomClient extends EventEmitter {
|
|
40
|
+
private ws: WebSocket | null = null;
|
|
41
|
+
private reconnectAttempt = 0;
|
|
42
|
+
private manuallyClosed = false;
|
|
43
|
+
private rejectedByServer = false;
|
|
44
|
+
private pingTimer: NodeJS.Timeout | null = null;
|
|
45
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly server: string,
|
|
49
|
+
private readonly roomCode: string,
|
|
50
|
+
private readonly username: string,
|
|
51
|
+
) {
|
|
52
|
+
super();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
connect(): void {
|
|
56
|
+
this.manuallyClosed = false;
|
|
57
|
+
this.rejectedByServer = false;
|
|
58
|
+
this.openSocket();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private openSocket(): void {
|
|
62
|
+
const url = `${normalizeWs(this.server)}/room/${encodeURIComponent(this.roomCode)}?username=${encodeURIComponent(this.username)}`;
|
|
63
|
+
const ws = new WebSocket(url);
|
|
64
|
+
this.ws = ws;
|
|
65
|
+
|
|
66
|
+
ws.on("open", () => {
|
|
67
|
+
this.reconnectAttempt = 0;
|
|
68
|
+
this.startPing();
|
|
69
|
+
// 真正"加入成功"由服务端 joined 消息确认;这里只表示链路通了
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
ws.on("message", (raw: Buffer | string) => {
|
|
73
|
+
let msg: { type?: string; payload?: unknown };
|
|
74
|
+
try {
|
|
75
|
+
msg = JSON.parse(raw.toString());
|
|
76
|
+
} catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.dispatch(msg);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// 服务端返回非 101 响应(房间不存在/已满/过期/限流)
|
|
83
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
84
|
+
let body = "";
|
|
85
|
+
res.on("data", (c: Buffer) => (body += c.toString()));
|
|
86
|
+
res.on("end", () => {
|
|
87
|
+
let info = { code: `http_${res.statusCode}`, message: "连接被服务端拒绝" };
|
|
88
|
+
try {
|
|
89
|
+
const j = JSON.parse(body);
|
|
90
|
+
if (j.error) info = { code: String(j.error), message: String(j.message ?? j.error) };
|
|
91
|
+
} catch {
|
|
92
|
+
/* keep default */
|
|
93
|
+
}
|
|
94
|
+
this.rejectedByServer = true;
|
|
95
|
+
this.emit("server_error", info);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
ws.on("close", () => {
|
|
100
|
+
this.stopPing();
|
|
101
|
+
if (this.manuallyClosed || this.rejectedByServer) {
|
|
102
|
+
this.emit("closed");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.scheduleReconnect();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
ws.on("error", () => {
|
|
109
|
+
// 网络层错误;后续 close 会触发重连流程,这里不单独抛出
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private dispatch(msg: { type?: string; payload?: any }) {
|
|
114
|
+
switch (msg.type) {
|
|
115
|
+
case "joined":
|
|
116
|
+
this.emit("joined", msg.payload as JoinedInfo);
|
|
117
|
+
break;
|
|
118
|
+
case "peer_joined":
|
|
119
|
+
this.emit("peer_joined", msg.payload);
|
|
120
|
+
break;
|
|
121
|
+
case "peer_left":
|
|
122
|
+
this.emit("peer_left", msg.payload);
|
|
123
|
+
break;
|
|
124
|
+
case "message":
|
|
125
|
+
this.emit("message", msg.payload as ChatMessage);
|
|
126
|
+
break;
|
|
127
|
+
case "room_closing":
|
|
128
|
+
this.manuallyClosed = true; // 房间销毁是终态
|
|
129
|
+
this.emit("room_closing", msg.payload as { reason: CloseReason });
|
|
130
|
+
break;
|
|
131
|
+
case "error":
|
|
132
|
+
this.emit("server_error", msg.payload);
|
|
133
|
+
break;
|
|
134
|
+
default:
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 发送一条已加密的消息(密文 + nonce)。 */
|
|
140
|
+
send(ciphertext: string, nonce: string): void {
|
|
141
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
142
|
+
this.ws.send(JSON.stringify({ type: "message", payload: { ciphertext, nonce } }));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
close(): void {
|
|
147
|
+
this.manuallyClosed = true;
|
|
148
|
+
this.stopPing();
|
|
149
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
150
|
+
this.ws?.close();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private startPing(): void {
|
|
154
|
+
this.stopPing();
|
|
155
|
+
this.pingTimer = setInterval(() => {
|
|
156
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
157
|
+
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
158
|
+
}
|
|
159
|
+
}, 25_000);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private stopPing(): void {
|
|
163
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
164
|
+
this.pingTimer = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private scheduleReconnect(): void {
|
|
168
|
+
this.reconnectAttempt += 1;
|
|
169
|
+
const delayMs = Math.min(1000 * 2 ** (this.reconnectAttempt - 1), 30_000);
|
|
170
|
+
this.emit("reconnecting", { attempt: this.reconnectAttempt, delayMs });
|
|
171
|
+
this.reconnectTimer = setTimeout(() => this.openSocket(), delayMs);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 把任意形式的地址规范化成 ws/wss 基础 URL(去尾部斜杠)。 */
|
|
176
|
+
function normalizeWs(server: string): string {
|
|
177
|
+
let s = server.trim().replace(/\/+$/, "");
|
|
178
|
+
if (s.startsWith("https://")) s = "wss://" + s.slice("https://".length);
|
|
179
|
+
else if (s.startsWith("http://")) s = "ws://" + s.slice("http://".length);
|
|
180
|
+
else if (!s.startsWith("ws://") && !s.startsWith("wss://")) s = "wss://" + s;
|
|
181
|
+
return s;
|
|
182
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"types": ["node"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
|
17
|
+
}
|
package/tsup.config.ts
ADDED