@yeaft/webchat-agent 1.0.186 → 1.0.189
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/cli.js +12 -0
- package/index.js +10 -2
- package/local-run.js +218 -0
- package/local-runtime/server/.env.example +54 -0
- package/local-runtime/server/api.js +111 -0
- package/local-runtime/server/auth/aad.js +235 -0
- package/local-runtime/server/auth/login.js +156 -0
- package/local-runtime/server/auth/oauth-flow.js +277 -0
- package/local-runtime/server/auth/password-reset.js +134 -0
- package/local-runtime/server/auth/providers/alipay.js +125 -0
- package/local-runtime/server/auth/providers/github.js +82 -0
- package/local-runtime/server/auth/providers/google.js +60 -0
- package/local-runtime/server/auth/providers/microsoft.js +71 -0
- package/local-runtime/server/auth/providers/types.js +57 -0
- package/local-runtime/server/auth/providers/wechat.js +68 -0
- package/local-runtime/server/auth/register.js +91 -0
- package/local-runtime/server/auth/session-store.js +57 -0
- package/local-runtime/server/auth/token.js +85 -0
- package/local-runtime/server/auth/totp-auth.js +133 -0
- package/local-runtime/server/auth/utils.js +42 -0
- package/local-runtime/server/auth.js +8 -0
- package/local-runtime/server/check-node-version.js +74 -0
- package/local-runtime/server/config.js +298 -0
- package/local-runtime/server/context.js +140 -0
- package/local-runtime/server/create-user.js +59 -0
- package/local-runtime/server/database.js +12 -0
- package/local-runtime/server/db/connection.js +963 -0
- package/local-runtime/server/db/expert-db.js +171 -0
- package/local-runtime/server/db/identity-db.js +92 -0
- package/local-runtime/server/db/invitation-db.js +38 -0
- package/local-runtime/server/db/message-db.js +257 -0
- package/local-runtime/server/db/session-db.js +118 -0
- package/local-runtime/server/db/user-db.js +165 -0
- package/local-runtime/server/db/user-stats-db.js +185 -0
- package/local-runtime/server/db/yeaft-session-db.js +258 -0
- package/local-runtime/server/email.js +96 -0
- package/local-runtime/server/encryption.js +105 -0
- package/local-runtime/server/handlers/agent-conversation.js +347 -0
- package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
- package/local-runtime/server/handlers/agent-output.js +854 -0
- package/local-runtime/server/handlers/agent-sync.js +399 -0
- package/local-runtime/server/handlers/agent-work-center.js +27 -0
- package/local-runtime/server/handlers/client-conversation.js +1182 -0
- package/local-runtime/server/handlers/client-misc.js +254 -0
- package/local-runtime/server/handlers/client-work-center.js +269 -0
- package/local-runtime/server/handlers/client-workbench.js +146 -0
- package/local-runtime/server/handlers/session-pin-router.js +61 -0
- package/local-runtime/server/heartbeat-policy.js +46 -0
- package/local-runtime/server/index.js +275 -0
- package/local-runtime/server/package.json +55 -0
- package/local-runtime/server/perf-trace.js +154 -0
- package/local-runtime/server/proxy.js +273 -0
- package/local-runtime/server/routes/admin-routes.js +207 -0
- package/local-runtime/server/routes/auth-routes.js +322 -0
- package/local-runtime/server/routes/expert-routes.js +117 -0
- package/local-runtime/server/routes/invitation-routes.js +60 -0
- package/local-runtime/server/routes/session-routes.js +112 -0
- package/local-runtime/server/routes/upload-routes.js +109 -0
- package/local-runtime/server/routes/user-routes.js +241 -0
- package/local-runtime/server/totp.js +74 -0
- package/local-runtime/server/work-item-attachment-policy.js +56 -0
- package/local-runtime/server/ws-agent.js +319 -0
- package/local-runtime/server/ws-client.js +214 -0
- package/local-runtime/server/ws-utils.js +394 -0
- package/local-runtime/server/yeaft-asset-store.js +339 -0
- package/local-runtime/version.json +1 -0
- package/local-runtime/web/app.bundle.js +7673 -0
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/assets/avatars/README.md +34 -0
- package/local-runtime/web/assets/avatars/ada.svg +1 -0
- package/local-runtime/web/assets/avatars/alan.svg +1 -0
- package/local-runtime/web/assets/avatars/alice.svg +1 -0
- package/local-runtime/web/assets/avatars/anders.svg +1 -0
- package/local-runtime/web/assets/avatars/bezos.svg +1 -0
- package/local-runtime/web/assets/avatars/borges.svg +1 -0
- package/local-runtime/web/assets/avatars/buffett.svg +1 -0
- package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
- package/local-runtime/web/assets/avatars/dalio.svg +1 -0
- package/local-runtime/web/assets/avatars/dieter.svg +1 -0
- package/local-runtime/web/assets/avatars/drucker.svg +1 -0
- package/local-runtime/web/assets/avatars/einstein.svg +1 -0
- package/local-runtime/web/assets/avatars/grace.svg +1 -0
- package/local-runtime/web/assets/avatars/harari.svg +1 -0
- package/local-runtime/web/assets/avatars/jung.svg +1 -0
- package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
- package/local-runtime/web/assets/avatars/ken.svg +1 -0
- package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
- package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
- package/local-runtime/web/assets/avatars/linus.svg +1 -0
- package/local-runtime/web/assets/avatars/luxun.svg +1 -0
- package/local-runtime/web/assets/avatars/margaret.svg +1 -0
- package/local-runtime/web/assets/avatars/martin.svg +1 -0
- package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
- package/local-runtime/web/assets/avatars/munger.svg +1 -0
- package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
- package/local-runtime/web/assets/avatars/norman.svg +1 -0
- package/local-runtime/web/assets/avatars/shannon.svg +1 -0
- package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
- package/local-runtime/web/assets/avatars/socrates.svg +1 -0
- package/local-runtime/web/assets/avatars/steve.svg +1 -0
- package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
- package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
- package/local-runtime/web/docx-preview.min.js +2 -0
- package/local-runtime/web/docx-preview.min.js.gz +0 -0
- package/local-runtime/web/html-to-image.min.js +2 -0
- package/local-runtime/web/html-to-image.min.js.gz +0 -0
- package/local-runtime/web/index.html +21 -0
- package/local-runtime/web/jszip.min.js +13 -0
- package/local-runtime/web/jszip.min.js.gz +0 -0
- package/local-runtime/web/mermaid.min.js +2843 -0
- package/local-runtime/web/mermaid.min.js.gz +0 -0
- package/local-runtime/web/msal-browser.min.js +69 -0
- package/local-runtime/web/msal-browser.min.js.gz +0 -0
- package/local-runtime/web/style.bundle.css +10 -0
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/local-runtime/web/vendor.bundle.js +1522 -0
- package/local-runtime/web/vendor.bundle.js.gz +0 -0
- package/local-runtime/web/xlsx.min.js +24 -0
- package/local-runtime/web/xlsx.min.js.gz +0 -0
- package/package.json +13 -3
- package/scripts/prepare-local-runtime.js +25 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-pin-router.js — pure routing decision for pin_session /
|
|
3
|
+
* unpin_session WebSocket messages.
|
|
4
|
+
*
|
|
5
|
+
* Yeaft and Chat sessions live in two different SQLite tables
|
|
6
|
+
* (`yeaft_sessions` vs `sessions`) with two different ownership
|
|
7
|
+
* conventions:
|
|
8
|
+
* - yeaft: rows carry their own `user_id` column (yeaft sessions are
|
|
9
|
+
* not chat conversations, they don't show up in the `sessions` table)
|
|
10
|
+
* - chat: `verifyConversationOwnership` reads `sessions.user_id`
|
|
11
|
+
*
|
|
12
|
+
* This module exposes a single pure decision function that returns a
|
|
13
|
+
* discriminated outcome. The production handler in
|
|
14
|
+
* `client-conversation.js` translates the outcome into the actual DB
|
|
15
|
+
* write + WS reply; tests use the same function to assert routing
|
|
16
|
+
* behavior without having to import the whole handler graph.
|
|
17
|
+
*
|
|
18
|
+
* Outcome shapes:
|
|
19
|
+
* { kind: 'noop' } — message had no conversationId
|
|
20
|
+
* { kind: 'yeaft', id, isPinned } — yeaft DB path
|
|
21
|
+
* { kind: 'chat', id, isPinned } — chat DB fallback path
|
|
22
|
+
* { kind: 'denied', id, reason: 'yeaft-foreign' | 'chat-foreign' } — ownership rejection
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} PinRouteDeps
|
|
27
|
+
* @property {(id: string) => ({ userId?: string|null }|null)} getYeaftRow Reads a yeaft session row (or returns null if not yeaft-owned).
|
|
28
|
+
* @property {(id: string, userId: string) => boolean} verifyChatOwnership Returns true if `userId` owns the chat conversation `id`.
|
|
29
|
+
* @property {boolean} [skipAuth] Skip both ownership checks (dev / single-user).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decide where (and whether) a pin_session / unpin_session message
|
|
34
|
+
* should be applied. Pure — never touches the DB.
|
|
35
|
+
*
|
|
36
|
+
* @param {PinRouteDeps} deps
|
|
37
|
+
* @param {{ userId: string|null }} client
|
|
38
|
+
* @param {{ type: 'pin_session'|'unpin_session', conversationId?: string }} msg
|
|
39
|
+
* @returns {
|
|
40
|
+
* {kind:'noop'} |
|
|
41
|
+
* {kind:'yeaft', id:string, isPinned:boolean} |
|
|
42
|
+
* {kind:'chat', id:string, isPinned:boolean} |
|
|
43
|
+
* {kind:'denied', id:string, reason:string}
|
|
44
|
+
* }
|
|
45
|
+
*/
|
|
46
|
+
export function routeSessionPin(deps, client, msg) {
|
|
47
|
+
const id = msg && msg.conversationId;
|
|
48
|
+
if (!id) return { kind: 'noop' };
|
|
49
|
+
const isPinned = msg.type === 'pin_session';
|
|
50
|
+
const yeaftRow = deps.getYeaftRow(id);
|
|
51
|
+
if (yeaftRow) {
|
|
52
|
+
if (!deps.skipAuth && yeaftRow.userId && yeaftRow.userId !== client.userId) {
|
|
53
|
+
return { kind: 'denied', id, reason: 'yeaft-foreign' };
|
|
54
|
+
}
|
|
55
|
+
return { kind: 'yeaft', id, isPinned };
|
|
56
|
+
}
|
|
57
|
+
if (!deps.skipAuth && !deps.verifyChatOwnership(id, client.userId)) {
|
|
58
|
+
return { kind: 'denied', id, reason: 'chat-foreign' };
|
|
59
|
+
}
|
|
60
|
+
return { kind: 'chat', id, isPinned };
|
|
61
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const DEFAULT_AGENT_HEARTBEAT_TIMEOUT_MS = 180000;
|
|
2
|
+
const DEFAULT_AGENT_HEARTBEAT_STALL_GRACE_MS = 5000;
|
|
3
|
+
|
|
4
|
+
function parsePositiveInt(value, fallback) {
|
|
5
|
+
const n = Number(value);
|
|
6
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const AGENT_HEARTBEAT_TIMEOUT_MS = parsePositiveInt(
|
|
10
|
+
process.env.AGENT_HEARTBEAT_TIMEOUT_MS,
|
|
11
|
+
DEFAULT_AGENT_HEARTBEAT_TIMEOUT_MS,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export const AGENT_HEARTBEAT_STALL_GRACE_MS = parsePositiveInt(
|
|
15
|
+
process.env.AGENT_HEARTBEAT_STALL_GRACE_MS,
|
|
16
|
+
DEFAULT_AGENT_HEARTBEAT_STALL_GRACE_MS,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export function markAgentHeartbeatSeen(agent, now = Date.now()) {
|
|
20
|
+
if (!agent) return;
|
|
21
|
+
agent.isAlive = true;
|
|
22
|
+
agent.lastSeenAt = now;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function markAgentHeartbeatPing(agent, now = Date.now()) {
|
|
26
|
+
if (!agent) return;
|
|
27
|
+
agent.pingSentAt = now;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function markAgentHeartbeatStall(agent, driftMs, now = Date.now()) {
|
|
31
|
+
if (!agent) return;
|
|
32
|
+
agent.lastHeartbeatStallAt = now;
|
|
33
|
+
agent.lastHeartbeatStallMs = driftMs;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function shouldTerminateAgentHeartbeat(
|
|
37
|
+
agent,
|
|
38
|
+
now = Date.now(),
|
|
39
|
+
timeoutMs = AGENT_HEARTBEAT_TIMEOUT_MS,
|
|
40
|
+
{ timerDriftMs = 0, stallGraceMs = AGENT_HEARTBEAT_STALL_GRACE_MS } = {},
|
|
41
|
+
) {
|
|
42
|
+
if (!agent) return false;
|
|
43
|
+
if (Number.isFinite(timerDriftMs) && timerDriftMs > stallGraceMs) return false;
|
|
44
|
+
const lastSeenAt = Number.isFinite(agent.lastSeenAt) ? agent.lastSeenAt : 0;
|
|
45
|
+
return lastSeenAt > 0 && (now - lastSeenAt) > timeoutMs;
|
|
46
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { assertNodeVersion } from './check-node-version.js';
|
|
2
|
+
assertNodeVersion({ component: 'yeaft-server' });
|
|
3
|
+
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import compression from 'compression';
|
|
6
|
+
import { createServer } from 'http';
|
|
7
|
+
import { WebSocketServer } from 'ws';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { dirname, join } from 'path';
|
|
10
|
+
import { CONFIG, isEmailConfigured, validateProductionConfig } from './config.js';
|
|
11
|
+
import { agents, webClients, userFileTabs, userStatsDeltas } from './context.js';
|
|
12
|
+
import { invitationDb, userStatsDb, closeDb } from './database.js';
|
|
13
|
+
import { registerApiRoutes } from './api.js';
|
|
14
|
+
import { registerProxyRoutes, handleProxyWebSocketUpgrade } from './proxy.js';
|
|
15
|
+
import { handleAgentConnection } from './ws-agent.js';
|
|
16
|
+
import { handleWebConnection } from './ws-client.js';
|
|
17
|
+
import { sendToWebClient } from './ws-utils.js';
|
|
18
|
+
import { markAgentHeartbeatPing, markAgentHeartbeatStall, shouldTerminateAgentHeartbeat } from './heartbeat-policy.js';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
const app = express();
|
|
23
|
+
app.set('trust proxy', 1);
|
|
24
|
+
const server = createServer(app);
|
|
25
|
+
// maxPayload: defaults to 256 MiB but is env-overridable so operators on
|
|
26
|
+
// constrained VMs can throttle without a redeploy. The Yeaft debug feature
|
|
27
|
+
// ships verbatim LLM raw request/response bodies through this WebSocket
|
|
28
|
+
// (see anthropic.js / openai-responses.js onRawExchange — payloads are
|
|
29
|
+
// never truncated). A pathological tool result or a long SSE stream can
|
|
30
|
+
// plausibly exceed the `ws` library's 100 MiB default. The default 256 MiB
|
|
31
|
+
// covers realistic worst case; the per-tab retention is bounded separately
|
|
32
|
+
// on the client by MAX_YEAFT_DEBUG_LOOPS in web/stores/chat.js.
|
|
33
|
+
//
|
|
34
|
+
// Memory math: with N concurrent agents each delivering one full-frame
|
|
35
|
+
// payload, the server transiently holds N × maxPayload before dispatch.
|
|
36
|
+
// On a small VM (≤1 GiB), set WS_MAX_PAYLOAD_BYTES lower (e.g. 64 MiB).
|
|
37
|
+
const DEFAULT_WS_MAX_PAYLOAD_BYTES = 256 * 1024 * 1024;
|
|
38
|
+
const envOverride = Number(process.env.WS_MAX_PAYLOAD_BYTES);
|
|
39
|
+
const WS_MAX_PAYLOAD_BYTES = Number.isFinite(envOverride) && envOverride > 0
|
|
40
|
+
? envOverride
|
|
41
|
+
: DEFAULT_WS_MAX_PAYLOAD_BYTES;
|
|
42
|
+
const wss = new WebSocketServer({
|
|
43
|
+
noServer: true,
|
|
44
|
+
maxPayload: WS_MAX_PAYLOAD_BYTES,
|
|
45
|
+
// RFC 7692 permessage-deflate. Browsers advertise this natively; the
|
|
46
|
+
// `ws` library handles compression streaming for the agent. Replaces
|
|
47
|
+
// the hand-rolled gzip-before-encrypt that ran only on the (now
|
|
48
|
+
// back-compat) encrypted send path. With plaintext outbound enabled,
|
|
49
|
+
// this is the only compression layer — payloads in DevTools still show
|
|
50
|
+
// the uncompressed JSON (browser inflates before exposing the frame).
|
|
51
|
+
perMessageDeflate: {
|
|
52
|
+
zlibDeflateOptions: { level: 6, memLevel: 7 },
|
|
53
|
+
zlibInflateOptions: { chunkSize: 10 * 1024 },
|
|
54
|
+
clientNoContextTakeover: true, // bound per-connection memory
|
|
55
|
+
serverNoContextTakeover: true,
|
|
56
|
+
threshold: 1024 // skip compression for tiny frames
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// =====================
|
|
61
|
+
// WebSocket 心跳机制
|
|
62
|
+
// =====================
|
|
63
|
+
const AGENT_HEARTBEAT_INTERVAL = 30000;
|
|
64
|
+
const CLIENT_HEARTBEAT_INTERVAL = 90000;
|
|
65
|
+
|
|
66
|
+
let lastAgentHeartbeatTickAt = Date.now();
|
|
67
|
+
setInterval(() => {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
const timerDriftMs = Math.max(0, now - lastAgentHeartbeatTickAt - AGENT_HEARTBEAT_INTERVAL);
|
|
70
|
+
lastAgentHeartbeatTickAt = now;
|
|
71
|
+
|
|
72
|
+
for (const [agentId, agent] of agents) {
|
|
73
|
+
if (timerDriftMs > 5000) {
|
|
74
|
+
markAgentHeartbeatStall(agent, timerDriftMs, now);
|
|
75
|
+
console.warn(`[Heartbeat] Server event loop delayed agent heartbeat by ${Math.round(timerDriftMs)}ms; deferring termination for ${agentId}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (shouldTerminateAgentHeartbeat(agent, now, undefined, { timerDriftMs })) {
|
|
79
|
+
console.log(`[Heartbeat] Agent ${agentId} not responding, terminating`);
|
|
80
|
+
agent.ws.terminate();
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
markAgentHeartbeatPing(agent, now);
|
|
84
|
+
agent.ws.ping();
|
|
85
|
+
}
|
|
86
|
+
}, AGENT_HEARTBEAT_INTERVAL);
|
|
87
|
+
|
|
88
|
+
setInterval(() => {
|
|
89
|
+
for (const [clientId, client] of webClients) {
|
|
90
|
+
if (client.isAlive === false) {
|
|
91
|
+
console.log(`[Heartbeat] Web client ${clientId} not responding, terminating`);
|
|
92
|
+
client.ws.terminate();
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
client.isAlive = false;
|
|
96
|
+
client.ws.ping();
|
|
97
|
+
}
|
|
98
|
+
}, CLIENT_HEARTBEAT_INTERVAL);
|
|
99
|
+
|
|
100
|
+
// ★ Phase 5: 每小时清理超过 24 小时的 file tab 状态
|
|
101
|
+
setInterval(() => {
|
|
102
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
103
|
+
for (const [key, state] of userFileTabs) {
|
|
104
|
+
if (state.timestamp < cutoff) userFileTabs.delete(key);
|
|
105
|
+
}
|
|
106
|
+
}, 60 * 60 * 1000);
|
|
107
|
+
|
|
108
|
+
// ★ Phase 6: 每小时清理过期的未使用邀请码
|
|
109
|
+
setInterval(() => {
|
|
110
|
+
invitationDb.cleanup();
|
|
111
|
+
}, 60 * 60 * 1000);
|
|
112
|
+
|
|
113
|
+
// ★ Admin Dashboard: 每 60 秒批量持久化用户统计增量到 DB
|
|
114
|
+
function flushUserStats() {
|
|
115
|
+
if (userStatsDeltas.size === 0) return;
|
|
116
|
+
try {
|
|
117
|
+
userStatsDb.flushDeltas(userStatsDeltas);
|
|
118
|
+
userStatsDeltas.clear();
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error('[UserStats] Flush error:', e.message);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
setInterval(flushUserStats, 60 * 1000);
|
|
124
|
+
|
|
125
|
+
// Gzip 压缩中间件
|
|
126
|
+
app.use(compression({
|
|
127
|
+
level: 6,
|
|
128
|
+
threshold: 1024,
|
|
129
|
+
filter: (req, res) => {
|
|
130
|
+
if (req.headers['x-no-compression']) return false;
|
|
131
|
+
// Skip compression for proxy routes (avoid buffering SSE/streaming)
|
|
132
|
+
if (req.path.startsWith('/agent/')) return false;
|
|
133
|
+
return compression.filter(req, res);
|
|
134
|
+
}
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
// 静态文件服务
|
|
138
|
+
const webDir = process.env.WEB_DIR || (process.env.SERVE_DIST === 'true'
|
|
139
|
+
? join(__dirname, '../web/dist')
|
|
140
|
+
: join(__dirname, '../web'));
|
|
141
|
+
app.use(express.static(webDir, {
|
|
142
|
+
maxAge: process.env.SERVE_DIST === 'true' ? '1y' : 0,
|
|
143
|
+
etag: true,
|
|
144
|
+
setHeaders: (res, filePath) => {
|
|
145
|
+
if (filePath.endsWith('.html')) {
|
|
146
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
// SPA fallback for OAuth callback routes (MSAL popup redirect)
|
|
152
|
+
app.get('/auth/callback', (req, res) => {
|
|
153
|
+
res.sendFile(join(webDir, 'index.html'));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Port proxy routes (must be before express.json() to get raw body)
|
|
157
|
+
registerProxyRoutes(app);
|
|
158
|
+
|
|
159
|
+
// JSON body parser — after proxy routes
|
|
160
|
+
app.use(express.json());
|
|
161
|
+
|
|
162
|
+
// API routes (auth, sessions, users, upload)
|
|
163
|
+
registerApiRoutes(app);
|
|
164
|
+
|
|
165
|
+
// =====================
|
|
166
|
+
// WebSocket 连接处理
|
|
167
|
+
// =====================
|
|
168
|
+
wss.on('connection', (ws, req) => {
|
|
169
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
170
|
+
const clientType = url.searchParams.get('type');
|
|
171
|
+
|
|
172
|
+
if (clientType === 'agent') {
|
|
173
|
+
handleAgentConnection(ws, url);
|
|
174
|
+
} else if (clientType === 'web') {
|
|
175
|
+
handleWebConnection(ws, url);
|
|
176
|
+
} else {
|
|
177
|
+
ws.close(1008, 'Invalid client type');
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// =====================
|
|
182
|
+
// HTTP Upgrade handler
|
|
183
|
+
// =====================
|
|
184
|
+
server.on('upgrade', (req, socket, head) => {
|
|
185
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
186
|
+
|
|
187
|
+
// Check if this is a proxy WebSocket request
|
|
188
|
+
const proxyMatch = url.pathname.match(/^\/agent\/([^/]+)\/(\d+)(\/.*)?$/);
|
|
189
|
+
if (proxyMatch) {
|
|
190
|
+
handleProxyWebSocketUpgrade(req, socket, head, proxyMatch);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Otherwise, hand off to the main WebSocket server
|
|
195
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
196
|
+
wss.emit('connection', ws, req);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Validate production configuration before starting
|
|
201
|
+
const configValidation = validateProductionConfig();
|
|
202
|
+
if (!configValidation.valid) {
|
|
203
|
+
console.error('\n========================================');
|
|
204
|
+
console.error('SECURITY CONFIGURATION ERROR');
|
|
205
|
+
console.error('========================================');
|
|
206
|
+
for (const error of configValidation.errors) {
|
|
207
|
+
console.error(` - ${error}`);
|
|
208
|
+
}
|
|
209
|
+
console.error('\nServer cannot start with default secrets in production mode.');
|
|
210
|
+
console.error('Please configure the following environment variables:');
|
|
211
|
+
console.error(' - JWT_SECRET: A secure random string for JWT signing');
|
|
212
|
+
console.error('\nOr set SKIP_AUTH=true for development mode (NOT recommended for production).');
|
|
213
|
+
console.error('========================================\n');
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
if (configValidation.warnings) {
|
|
217
|
+
console.warn('\n⚠ Configuration warnings:');
|
|
218
|
+
for (const w of configValidation.warnings) {
|
|
219
|
+
console.warn(` - ${w}`);
|
|
220
|
+
}
|
|
221
|
+
console.warn('');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
server.listen(CONFIG.port, CONFIG.host, () => {
|
|
225
|
+
console.log(`Server running on http://${CONFIG.host || '0.0.0.0'}:${CONFIG.port}`);
|
|
226
|
+
console.log(`Auth mode: ${CONFIG.skipAuth ? 'SKIP (development)' : 'ENABLED'}`);
|
|
227
|
+
if (!CONFIG.skipAuth) {
|
|
228
|
+
console.log(`Users configured: ${CONFIG.users.length}`);
|
|
229
|
+
console.log(`Email verification: ${isEmailConfigured() ? 'ENABLED' : 'DISABLED'}`);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// =====================
|
|
234
|
+
// 优雅关闭(Graceful Shutdown)
|
|
235
|
+
// =====================
|
|
236
|
+
async function gracefulShutdown(signal) {
|
|
237
|
+
console.log(`\n[Shutdown] Received ${signal}, starting graceful shutdown...`);
|
|
238
|
+
|
|
239
|
+
// 1. 通知所有 web client 服务即将更新
|
|
240
|
+
const updateMsg = { type: 'server_updating' };
|
|
241
|
+
for (const [, client] of webClients) {
|
|
242
|
+
try {
|
|
243
|
+
await sendToWebClient(client, updateMsg);
|
|
244
|
+
} catch (e) { /* ignore send errors during shutdown */ }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 2. 短暂等待,确保消息发送完毕
|
|
248
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
249
|
+
|
|
250
|
+
// 3. 关闭所有 WebSocket 连接
|
|
251
|
+
for (const [, client] of webClients) {
|
|
252
|
+
try { client.ws.close(1012, 'Server restarting'); } catch (e) {}
|
|
253
|
+
}
|
|
254
|
+
for (const [, agent] of agents) {
|
|
255
|
+
try { agent.ws.close(1012, 'Server restarting'); } catch (e) {}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 4. 停止接受新连接
|
|
259
|
+
server.close(() => {
|
|
260
|
+
console.log('[Shutdown] HTTP server closed');
|
|
261
|
+
// Flush pending user stats before closing DB
|
|
262
|
+
flushUserStats();
|
|
263
|
+
closeDb();
|
|
264
|
+
process.exit(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// 5. 强制退出兜底(5 秒超时)
|
|
268
|
+
setTimeout(() => {
|
|
269
|
+
console.warn('[Shutdown] Forced exit after timeout');
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}, 5000);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
275
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yeaft/webchat-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Central WebSocket server for Yeaft Web Code Agent",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node index.js",
|
|
9
|
+
"dev": "nodemon index.js",
|
|
10
|
+
"dev:noauth": "cross-env SKIP_AUTH=true nodemon index.js",
|
|
11
|
+
"dev:auth": "nodemon index.js"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.5.0"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/yeaft/claude-web-chat",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/yeaft/claude-web-chat.git",
|
|
20
|
+
"directory": "server"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/yeaft/claude-web-chat/issues"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": "Yeaft",
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"cross-env": "^10.1.0",
|
|
29
|
+
"nodemon": "^3.1.14"
|
|
30
|
+
},
|
|
31
|
+
"nodemonConfig": {
|
|
32
|
+
"watch": [
|
|
33
|
+
"*.js"
|
|
34
|
+
],
|
|
35
|
+
"ignore": [
|
|
36
|
+
"node_modules",
|
|
37
|
+
"package-lock.json"
|
|
38
|
+
],
|
|
39
|
+
"ext": "js"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"bcrypt": "^6.0.0",
|
|
43
|
+
"compression": "^1.8.1",
|
|
44
|
+
"dotenv": "^16.3.1",
|
|
45
|
+
"express": "^4.22.2",
|
|
46
|
+
"jsonwebtoken": "^9.0.2",
|
|
47
|
+
"multer": "^2.2.0",
|
|
48
|
+
"nodemailer": "^9.0.3",
|
|
49
|
+
"qrcode": "^1.5.4",
|
|
50
|
+
"speakeasy": "^2.0.0",
|
|
51
|
+
"tweetnacl": "^1.0.3",
|
|
52
|
+
"tweetnacl-util": "^0.15.1",
|
|
53
|
+
"ws": "^8.21.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname } from 'path';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
|
|
9
|
+
const MAX_QUEUE_SIZE = 5000;
|
|
10
|
+
const MAX_STRING_LENGTH = 512;
|
|
11
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 1000;
|
|
12
|
+
const DEFAULT_TRACE_DIR = process.env.SERVER_DATA_DIR
|
|
13
|
+
? join(process.env.SERVER_DATA_DIR, 'perf-traces')
|
|
14
|
+
: join(__dirname, 'data', 'perf-traces');
|
|
15
|
+
const DEFAULT_RETENTION_DAYS = 3;
|
|
16
|
+
let lastCleanupDay = null;
|
|
17
|
+
|
|
18
|
+
let flushTimer = null;
|
|
19
|
+
const queue = [];
|
|
20
|
+
|
|
21
|
+
function enabled() {
|
|
22
|
+
return process.env.PERF_TRACE_DISABLED !== 'true';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function traceDir() {
|
|
26
|
+
return process.env.PERF_TRACE_DIR || DEFAULT_TRACE_DIR;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function retentionDays() {
|
|
30
|
+
const raw = Number(process.env.PERF_TRACE_RETENTION_DAYS || DEFAULT_RETENTION_DAYS);
|
|
31
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_RETENTION_DAYS;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cleanupOldTraceFiles(dir) {
|
|
35
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
36
|
+
if (lastCleanupDay === day) return;
|
|
37
|
+
lastCleanupDay = day;
|
|
38
|
+
const cutoff = Date.now() - retentionDays() * 24 * 60 * 60 * 1000;
|
|
39
|
+
try {
|
|
40
|
+
for (const file of readdirSync(dir)) {
|
|
41
|
+
const match = file.match(/^(\d{4}-\d{2}-\d{2})\.jsonl$/);
|
|
42
|
+
if (!match) continue;
|
|
43
|
+
const ts = Date.parse(`${match[1]}T00:00:00.000Z`);
|
|
44
|
+
if (Number.isFinite(ts) && ts < cutoff) rmSync(join(dir, file), { force: true });
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// best-effort; trace writes must never break relay
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function nowWall() {
|
|
52
|
+
return Date.now();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sanitizeString(value, max = MAX_STRING_LENGTH) {
|
|
56
|
+
if (typeof value !== 'string') return value;
|
|
57
|
+
return value.length > max ? `${value.slice(0, max)}...[truncated]` : value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sanitizeValue(value, depth = 0) {
|
|
61
|
+
if (value == null) return value;
|
|
62
|
+
if (typeof value === 'string') return sanitizeString(value);
|
|
63
|
+
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
if (depth >= 4) return `[array:${value.length}]`;
|
|
66
|
+
return value.slice(0, 50).map(v => sanitizeValue(v, depth + 1));
|
|
67
|
+
}
|
|
68
|
+
if (typeof value === 'object') {
|
|
69
|
+
if (depth >= 4) return '[object]';
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const [key, item] of Object.entries(value)) {
|
|
72
|
+
if (key === 'text' || key === 'prompt' || key === 'content' || key === 'data' || key === 'apiKey' || key === 'token') continue;
|
|
73
|
+
out[key] = sanitizeValue(item, depth + 1);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
return String(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeEvent(event = {}) {
|
|
81
|
+
const traceId = typeof event.traceId === 'string' && event.traceId.trim()
|
|
82
|
+
? event.traceId.trim()
|
|
83
|
+
: (typeof event.perfTraceId === 'string' && event.perfTraceId.trim() ? event.perfTraceId.trim() : null);
|
|
84
|
+
const phase = typeof event.phase === 'string' && event.phase.trim() ? event.phase.trim() : 'unknown';
|
|
85
|
+
const source = typeof event.source === 'string' && event.source.trim() ? event.source.trim() : 'unknown';
|
|
86
|
+
return {
|
|
87
|
+
traceId,
|
|
88
|
+
source,
|
|
89
|
+
phase,
|
|
90
|
+
at: Number.isFinite(event.at) ? event.at : nowWall(),
|
|
91
|
+
monotonicMs: Number.isFinite(event.monotonicMs) ? event.monotonicMs : null,
|
|
92
|
+
durationMs: Number.isFinite(event.durationMs) ? event.durationMs : null,
|
|
93
|
+
userId: event.userId || null,
|
|
94
|
+
agentId: event.agentId || null,
|
|
95
|
+
sessionId: event.sessionId || null,
|
|
96
|
+
vpId: event.vpId || null,
|
|
97
|
+
turnId: event.turnId || null,
|
|
98
|
+
threadId: event.threadId || null,
|
|
99
|
+
messageType: event.messageType || null,
|
|
100
|
+
bytes: Number.isFinite(event.bytes) ? event.bytes : null,
|
|
101
|
+
ok: typeof event.ok === 'boolean' ? event.ok : null,
|
|
102
|
+
detail: sanitizeValue(event.detail || null),
|
|
103
|
+
createdAt: new Date().toISOString(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function scheduleFlush() {
|
|
108
|
+
if (flushTimer || !enabled()) return;
|
|
109
|
+
const delay = Number(process.env.PERF_TRACE_FLUSH_INTERVAL_MS || DEFAULT_FLUSH_INTERVAL_MS);
|
|
110
|
+
flushTimer = setTimeout(() => {
|
|
111
|
+
flushTimer = null;
|
|
112
|
+
try { flushPerfTraceEvents(); }
|
|
113
|
+
catch (err) { console.warn('[PerfTrace] flush failed:', err?.message || err); }
|
|
114
|
+
}, Number.isFinite(delay) && delay >= 0 ? delay : DEFAULT_FLUSH_INTERVAL_MS);
|
|
115
|
+
if (typeof flushTimer.unref === 'function') flushTimer.unref();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function recordPerfTraceEvent(event) {
|
|
119
|
+
if (!enabled()) return false;
|
|
120
|
+
const normalized = normalizeEvent(event);
|
|
121
|
+
if (!normalized.traceId) return false;
|
|
122
|
+
if (queue.length >= MAX_QUEUE_SIZE) queue.shift();
|
|
123
|
+
queue.push(normalized);
|
|
124
|
+
scheduleFlush();
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function flushPerfTraceEvents() {
|
|
129
|
+
if (!enabled() || queue.length === 0) return 0;
|
|
130
|
+
const batch = queue.splice(0, queue.length);
|
|
131
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
132
|
+
const dir = traceDir();
|
|
133
|
+
mkdirSync(dir, { recursive: true });
|
|
134
|
+
cleanupOldTraceFiles(dir);
|
|
135
|
+
const file = join(dir, `${day}.jsonl`);
|
|
136
|
+
appendFileSync(file, batch.map(row => JSON.stringify(row)).join('\n') + '\n');
|
|
137
|
+
return batch.length;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function closePerfTraceStore() {
|
|
141
|
+
if (flushTimer) {
|
|
142
|
+
clearTimeout(flushTimer);
|
|
143
|
+
flushTimer = null;
|
|
144
|
+
}
|
|
145
|
+
try { flushPerfTraceEvents(); } catch { /* best-effort */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const __perfTraceForTest = {
|
|
149
|
+
normalizeEvent,
|
|
150
|
+
sanitizeValue,
|
|
151
|
+
queue,
|
|
152
|
+
traceDir,
|
|
153
|
+
cleanupOldTraceFiles,
|
|
154
|
+
};
|