@claudelaw/taichu 0.6.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/.dockerignore +13 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/docker-compose.yml +42 -0
- package/docs/ROADMAP.md +101 -0
- package/docs/api/README.md +102 -0
- package/docs/architecture/001-zero-dependency-core.md +61 -0
- package/docs/architecture/002-structured-content-model.md +70 -0
- package/docs/architecture/003-hook-based-extension.md +82 -0
- package/docs/architecture/004-api-first-architecture.md +122 -0
- package/docs/architecture/README.md +24 -0
- package/docs/logo.svg +40 -0
- package/docs/research/ai-era-cms-user-research.md +247 -0
- package/docs/zh/README.md +81 -0
- package/docs/zh/guides/deploy.md +75 -0
- package/docs/zh/guides/mcp.md +84 -0
- package/docs/zh/guides/promotion.md +51 -0
- package/marketplace.json +78 -0
- package/package.json +60 -0
- package/packages/core/src/auth.js +158 -0
- package/packages/core/src/content-type.js +244 -0
- package/packages/core/src/core.test.js +406 -0
- package/packages/core/src/errors.js +60 -0
- package/packages/core/src/hooks.js +104 -0
- package/packages/core/src/index.js +16 -0
- package/packages/core/src/server.test.js +149 -0
- package/packages/core/src/sm-crypto.js +31 -0
- package/packages/core/src/sqlite-store.js +354 -0
- package/packages/core/src/store.js +174 -0
- package/packages/core/src/tokenizer.js +89 -0
- package/packages/core/src/vector-index.js +131 -0
- package/packages/llm-providers/src/index.js +181 -0
- package/packages/mcp/src/index.js +355 -0
- package/packages/server/public/admin/assets/index-DApxOVTx.js +191 -0
- package/packages/server/public/admin/assets/index-DtMvdQm9.css +1 -0
- package/packages/server/public/admin/index.html +28 -0
- package/packages/server/public/aurora/style.css +1173 -0
- package/packages/server/public/favicon.svg +46 -0
- package/packages/server/public/theme/index.html +288 -0
- package/packages/server/public/theme/style.css +133 -0
- package/packages/server/public/theme-minimal/index.html +223 -0
- package/packages/server/public/theme-minimal/style.css +109 -0
- package/packages/server/public/ws-test.html +106 -0
- package/packages/server/src/activitypub.js +228 -0
- package/packages/server/src/audit.js +104 -0
- package/packages/server/src/auth-provider.js +76 -0
- package/packages/server/src/body-parser.js +52 -0
- package/packages/server/src/bootstrap.js +272 -0
- package/packages/server/src/collab.js +154 -0
- package/packages/server/src/config.js +136 -0
- package/packages/server/src/context.js +86 -0
- package/packages/server/src/email.js +317 -0
- package/packages/server/src/index.js +195 -0
- package/packages/server/src/logger.js +78 -0
- package/packages/server/src/media-store.js +213 -0
- package/packages/server/src/middleware/auth.js +203 -0
- package/packages/server/src/middleware/cors.js +15 -0
- package/packages/server/src/middleware/error-handler.js +49 -0
- package/packages/server/src/middleware/rate-limit.js +118 -0
- package/packages/server/src/multipart.js +150 -0
- package/packages/server/src/notify.js +126 -0
- package/packages/server/src/pipeline.js +206 -0
- package/packages/server/src/plugin-installer.js +139 -0
- package/packages/server/src/plugin-manager.js +165 -0
- package/packages/server/src/relationships.js +217 -0
- package/packages/server/src/revisions.js +114 -0
- package/packages/server/src/router.js +194 -0
- package/packages/server/src/routes/activitypub.js +140 -0
- package/packages/server/src/routes/api.js +363 -0
- package/packages/server/src/routes/audit.js +222 -0
- package/packages/server/src/routes/auth.js +205 -0
- package/packages/server/src/routes/collab.js +90 -0
- package/packages/server/src/routes/export.js +77 -0
- package/packages/server/src/routes/graphql.js +344 -0
- package/packages/server/src/routes/media.js +169 -0
- package/packages/server/src/routes/plugin-marketplace.js +171 -0
- package/packages/server/src/routes/relationships.js +133 -0
- package/packages/server/src/routes/rss.js +92 -0
- package/packages/server/src/routes/sso.js +211 -0
- package/packages/server/src/routes/theme.js +119 -0
- package/packages/server/src/routes/webhook.js +94 -0
- package/packages/server/src/routes/wechat.js +115 -0
- package/packages/server/src/routes/workflow.js +157 -0
- package/packages/server/src/scheduler.js +96 -0
- package/packages/server/src/search.js +100 -0
- package/packages/server/src/server.test.js +295 -0
- package/packages/server/src/sso-analytics.js +78 -0
- package/packages/server/src/static.js +70 -0
- package/packages/server/src/theme-engine.js +119 -0
- package/packages/server/src/webhook.js +192 -0
- package/packages/server/src/websocket.js +308 -0
- package/scripts/cli.js +90 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook System — 内容变更事件推送
|
|
3
|
+
*
|
|
4
|
+
* 注册外部 URL,当内容变更时 POST 事件数据。
|
|
5
|
+
*
|
|
6
|
+
* 功能:
|
|
7
|
+
* - Webhook 注册/列表/删除 API
|
|
8
|
+
* - 事件过滤(按内容类型、操作类型)
|
|
9
|
+
* - HMAC-SHA256 签名验证
|
|
10
|
+
* - 指数退避重试(最多 3 次)
|
|
11
|
+
* - 投递日志
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
15
|
+
import { createLogger } from './logger.js';
|
|
16
|
+
|
|
17
|
+
const log = createLogger('webhook');
|
|
18
|
+
|
|
19
|
+
const MAX_RETRIES = parseInt(process.env.TAICHU_WEBHOOK_RETRIES) || 3;
|
|
20
|
+
const RETRY_BASE_MS = parseInt(process.env.TAICHU_WEBHOOK_RETRY_BASE_MS) || 1000; // 1s, 2s, 4s
|
|
21
|
+
|
|
22
|
+
class WebhookManager {
|
|
23
|
+
constructor(store) {
|
|
24
|
+
this.store = store;
|
|
25
|
+
this.deliveryLog = [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Register a new webhook.
|
|
30
|
+
* @param {{ url: string, events?: string[], types?: string[], secret?: string, label?: string }} opts
|
|
31
|
+
* @returns {Promise<object>}
|
|
32
|
+
*/
|
|
33
|
+
async register({ url, events = ['*'], types = ['*'], secret, label }) {
|
|
34
|
+
const id = randomBytes(12).toString('hex');
|
|
35
|
+
const webhook = {
|
|
36
|
+
id, url, events, types,
|
|
37
|
+
secret: secret || randomBytes(16).toString('hex'),
|
|
38
|
+
label: label || url,
|
|
39
|
+
active: true,
|
|
40
|
+
createdAt: new Date().toISOString(),
|
|
41
|
+
stats: { delivered: 0, failed: 0, lastDelivery: null }
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const doc = await this.store.create({
|
|
45
|
+
type: 'webhook',
|
|
46
|
+
data: webhook,
|
|
47
|
+
status: 'active'
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
log.info(`Webhook registered: ${label || url} (${id})`);
|
|
51
|
+
return { id: doc.id, ...webhook };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* List all registered webhooks.
|
|
56
|
+
*/
|
|
57
|
+
async list() {
|
|
58
|
+
const docs = await this.store.list({ type: 'webhook' });
|
|
59
|
+
return docs.map(d => ({ id: d.id, ...d.data }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Delete a webhook.
|
|
64
|
+
*/
|
|
65
|
+
async remove(id) {
|
|
66
|
+
await this.store.delete(id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fire event to all matching webhooks (called by hook system).
|
|
71
|
+
* @param {string} event — "create" | "update" | "delete" | "publish"
|
|
72
|
+
* @param {object} payload — { id, type, data, status, ... }
|
|
73
|
+
*/
|
|
74
|
+
async fire(event, payload) {
|
|
75
|
+
const hooks = await this.list();
|
|
76
|
+
const active = hooks.filter(h => h.active);
|
|
77
|
+
if (active.length === 0) return;
|
|
78
|
+
|
|
79
|
+
for (const wh of active) {
|
|
80
|
+
if (!this._matches(wh, event, payload)) continue;
|
|
81
|
+
await this._deliver(wh, event, payload);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Check if webhook matches event + payload type */
|
|
86
|
+
_matches(wh, event, payload) {
|
|
87
|
+
const eventMatch = wh.events.includes('*') || wh.events.includes(event);
|
|
88
|
+
const typeMatch = wh.types.includes('*') || wh.types.includes(payload.type);
|
|
89
|
+
return eventMatch && typeMatch;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Deliver event with retry */
|
|
93
|
+
async _deliver(wh, event, payload) {
|
|
94
|
+
const deliveryId = randomBytes(6).toString('hex');
|
|
95
|
+
const body = JSON.stringify({
|
|
96
|
+
id: deliveryId,
|
|
97
|
+
event,
|
|
98
|
+
type: payload.type,
|
|
99
|
+
timestamp: new Date().toISOString(),
|
|
100
|
+
data: payload
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const signature = createHmac('sha256', wh.secret)
|
|
104
|
+
.update(body)
|
|
105
|
+
.digest('hex');
|
|
106
|
+
|
|
107
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
108
|
+
try {
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
111
|
+
|
|
112
|
+
const res = await fetch(wh.url, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: {
|
|
115
|
+
'Content-Type': 'application/json',
|
|
116
|
+
'X-Taichu-Webhook-Event': event,
|
|
117
|
+
'X-Taichu-Webhook-Signature': `sha256=${signature}`,
|
|
118
|
+
'X-Taichu-Webhook-Id': deliveryId
|
|
119
|
+
},
|
|
120
|
+
body,
|
|
121
|
+
signal: controller.signal
|
|
122
|
+
});
|
|
123
|
+
clearTimeout(timeout);
|
|
124
|
+
|
|
125
|
+
if (res.ok) {
|
|
126
|
+
this._logDelivery(wh.id, deliveryId, true, attempt);
|
|
127
|
+
// Update stats
|
|
128
|
+
const doc = await this.store.get(wh.id);
|
|
129
|
+
if (doc) {
|
|
130
|
+
doc.data.stats.delivered++;
|
|
131
|
+
doc.data.stats.lastDelivery = new Date().toISOString();
|
|
132
|
+
await this.store.update(wh.id, { data: doc.data });
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Non-2xx response — retry if server error
|
|
138
|
+
if (res.status >= 500) throw new Error(`Server error: ${res.status}`);
|
|
139
|
+
// 4xx — no retry
|
|
140
|
+
this._logDelivery(wh.id, deliveryId, false, attempt, `HTTP ${res.status}`);
|
|
141
|
+
break;
|
|
142
|
+
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (attempt < MAX_RETRIES) {
|
|
145
|
+
const delay = RETRY_BASE_MS * Math.pow(2, attempt - 1);
|
|
146
|
+
log.warn(`Webhook retry ${attempt}/${MAX_RETRIES} for ${wh.url}: ${err.message}`);
|
|
147
|
+
await new Promise(r => setTimeout(r, delay));
|
|
148
|
+
} else {
|
|
149
|
+
this._logDelivery(wh.id, deliveryId, false, attempt, err.message);
|
|
150
|
+
const doc = await this.store.get(wh.id);
|
|
151
|
+
if (doc) {
|
|
152
|
+
doc.data.stats.failed++;
|
|
153
|
+
await this.store.update(wh.id, { data: doc.data });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
_logDelivery(webhookId, deliveryId, success, attempt, error) {
|
|
161
|
+
const entry = {
|
|
162
|
+
webhookId, deliveryId, success, attempt,
|
|
163
|
+
timestamp: new Date().toISOString(),
|
|
164
|
+
error: error || null
|
|
165
|
+
};
|
|
166
|
+
this.deliveryLog.push(entry);
|
|
167
|
+
if (this.deliveryLog.length > 1000) this.deliveryLog.shift();
|
|
168
|
+
|
|
169
|
+
if (!success) log.error(`Webhook delivery failed: ${webhookId} — ${error}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Get delivery log */
|
|
173
|
+
getLog(limit = 50) {
|
|
174
|
+
return this.deliveryLog.slice(-limit).reverse();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Get stats */
|
|
178
|
+
getStats() {
|
|
179
|
+
const recent = this.deliveryLog.slice(-100);
|
|
180
|
+
const success = recent.filter(e => e.success).length;
|
|
181
|
+
return { recent: recent.length, success, failed: recent.length - success };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Singleton ──────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
let _wm = null;
|
|
188
|
+
|
|
189
|
+
export function getWebhookManager(store) {
|
|
190
|
+
if (!_wm && store) _wm = new WebhookManager(store);
|
|
191
|
+
return _wm;
|
|
192
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Server — 实时内容变更推送
|
|
3
|
+
*
|
|
4
|
+
* 零依赖,基于 Node.js 原生 WebSocket (RFC 6455)。
|
|
5
|
+
*
|
|
6
|
+
* 功能:
|
|
7
|
+
* - 频道订阅(按内容类型:article, page, * 等)
|
|
8
|
+
* - 事件广播(create/update/delete/publish)
|
|
9
|
+
* - 连接心跳(30s ping)
|
|
10
|
+
* - 客户端数量追踪
|
|
11
|
+
*
|
|
12
|
+
* 协议:
|
|
13
|
+
* Client → Server: { type: "subscribe", channel: "article" }
|
|
14
|
+
* Server → Client: { type: "content_change", event: "update", channel: "article", doc: {...} }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
18
|
+
import { EventEmitter } from 'node:events';
|
|
19
|
+
|
|
20
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
21
|
+
const HEARTBEAT_INTERVAL = 30000;
|
|
22
|
+
|
|
23
|
+
class WebSocketServer extends EventEmitter {
|
|
24
|
+
constructor() {
|
|
25
|
+
super();
|
|
26
|
+
/** @type {Map<string, WebSocket>} */
|
|
27
|
+
this.clients = new Map();
|
|
28
|
+
/** @type {Map<string, Set<string>>} channel → clientIds */
|
|
29
|
+
this.subscriptions = new Map();
|
|
30
|
+
this.heartbeatTimer = null;
|
|
31
|
+
this.stats = { connected: 0, messagesReceived: 0, messagesSent: 0 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Attach WebSocket upgrade handler to an existing HTTP server.
|
|
36
|
+
* @param {import('node:http').Server} httpServer
|
|
37
|
+
*/
|
|
38
|
+
attach(httpServer) {
|
|
39
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
40
|
+
if (req.headers['upgrade']?.toLowerCase() !== 'websocket') return;
|
|
41
|
+
|
|
42
|
+
const key = req.headers['sec-websocket-key'];
|
|
43
|
+
if (!key) {
|
|
44
|
+
socket.destroy();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Accept the WebSocket connection
|
|
49
|
+
const acceptKey = createHash('sha1')
|
|
50
|
+
.update(key + WS_GUID)
|
|
51
|
+
.digest('base64');
|
|
52
|
+
|
|
53
|
+
socket.write(
|
|
54
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
55
|
+
'Upgrade: websocket\r\n' +
|
|
56
|
+
'Connection: Upgrade\r\n' +
|
|
57
|
+
`Sec-WebSocket-Accept: ${acceptKey}\r\n\r\n`
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const ws = new WebSocket(socket);
|
|
61
|
+
const clientId = randomBytes(8).toString('hex');
|
|
62
|
+
this.clients.set(clientId, ws);
|
|
63
|
+
this.stats.connected++;
|
|
64
|
+
|
|
65
|
+
this.emit('connect', clientId);
|
|
66
|
+
|
|
67
|
+
ws.on('message', (data) => {
|
|
68
|
+
this.stats.messagesReceived++;
|
|
69
|
+
this._handleMessage(clientId, data);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
ws.on('close', () => {
|
|
73
|
+
this.clients.delete(clientId);
|
|
74
|
+
this.stats.connected = this.clients.size;
|
|
75
|
+
// Remove from all subscriptions
|
|
76
|
+
for (const [, subs] of this.subscriptions) {
|
|
77
|
+
subs.delete(clientId);
|
|
78
|
+
}
|
|
79
|
+
this.emit('disconnect', clientId);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
ws.on('error', () => {
|
|
83
|
+
ws.close();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Start heartbeat
|
|
88
|
+
this.heartbeatTimer = setInterval(() => {
|
|
89
|
+
for (const [, ws] of this.clients) {
|
|
90
|
+
ws.ping();
|
|
91
|
+
}
|
|
92
|
+
}, HEARTBEAT_INTERVAL);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Broadcast a content change event to subscribers of a channel.
|
|
97
|
+
* @param {string} channel — content type name or "*" for all
|
|
98
|
+
* @param {string} event — "create" | "update" | "delete" | "publish" | "archive"
|
|
99
|
+
* @param {object} payload
|
|
100
|
+
*/
|
|
101
|
+
broadcast(channel, event, payload) {
|
|
102
|
+
const channels = [channel, '*'];
|
|
103
|
+
const sentTo = new Set();
|
|
104
|
+
|
|
105
|
+
for (const ch of channels) {
|
|
106
|
+
const subs = this.subscriptions.get(ch);
|
|
107
|
+
if (!subs) continue;
|
|
108
|
+
for (const clientId of subs) {
|
|
109
|
+
if (sentTo.has(clientId)) continue;
|
|
110
|
+
const ws = this.clients.get(clientId);
|
|
111
|
+
if (!ws) continue;
|
|
112
|
+
ws.send(JSON.stringify({
|
|
113
|
+
type: 'content_change',
|
|
114
|
+
channel,
|
|
115
|
+
event,
|
|
116
|
+
doc: payload,
|
|
117
|
+
ts: new Date().toISOString()
|
|
118
|
+
}));
|
|
119
|
+
sentTo.add(clientId);
|
|
120
|
+
this.stats.messagesSent++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get current server stats.
|
|
127
|
+
*/
|
|
128
|
+
getStats() {
|
|
129
|
+
const channels = {};
|
|
130
|
+
for (const [ch, subs] of this.subscriptions) {
|
|
131
|
+
channels[ch] = subs.size;
|
|
132
|
+
}
|
|
133
|
+
return { ...this.stats, channels };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Close all connections */
|
|
137
|
+
close() {
|
|
138
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
139
|
+
for (const [, ws] of this.clients) {
|
|
140
|
+
ws.close(1001, 'Server shutting down');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_handleMessage(clientId, raw) {
|
|
145
|
+
try {
|
|
146
|
+
if (typeof raw !== 'string') return;
|
|
147
|
+
const msg = JSON.parse(raw);
|
|
148
|
+
|
|
149
|
+
switch (msg.type) {
|
|
150
|
+
case 'subscribe': {
|
|
151
|
+
const channel = msg.channel || '*';
|
|
152
|
+
if (!this.subscriptions.has(channel)) {
|
|
153
|
+
this.subscriptions.set(channel, new Set());
|
|
154
|
+
}
|
|
155
|
+
this.subscriptions.get(channel).add(clientId);
|
|
156
|
+
const ws = this.clients.get(clientId);
|
|
157
|
+
if (ws) ws.send(JSON.stringify({ type: 'subscribed', channel }));
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case 'unsubscribe': {
|
|
161
|
+
const channel = msg.channel || '*';
|
|
162
|
+
const subs = this.subscriptions.get(channel);
|
|
163
|
+
if (subs) subs.delete(clientId);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} catch { /* ignore malformed */ }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── WebSocket Frame Protocol ───────────────────────────────
|
|
172
|
+
|
|
173
|
+
class WebSocket {
|
|
174
|
+
constructor(socket) {
|
|
175
|
+
this.socket = socket;
|
|
176
|
+
this._buffer = Buffer.alloc(0);
|
|
177
|
+
this._closed = false;
|
|
178
|
+
|
|
179
|
+
socket.on('data', (chunk) => {
|
|
180
|
+
this._buffer = Buffer.concat([this._buffer, chunk]);
|
|
181
|
+
this._parseFrames();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
socket.on('close', () => {
|
|
185
|
+
this._closed = true;
|
|
186
|
+
this.emit('close');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
socket.on('error', () => {
|
|
190
|
+
this._closed = true;
|
|
191
|
+
this.emit('error');
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_listeners = {};
|
|
196
|
+
|
|
197
|
+
on(ev, fn) { (this._listeners[ev] ||= []).push(fn); }
|
|
198
|
+
emit(ev, ...args) { (this._listeners[ev] || []).forEach(f => f(...args)); }
|
|
199
|
+
|
|
200
|
+
send(data) {
|
|
201
|
+
if (this._closed) return;
|
|
202
|
+
try {
|
|
203
|
+
const payload = Buffer.from(data);
|
|
204
|
+
const frame = this._encodeFrame(payload, 0x1); // text frame
|
|
205
|
+
this.socket.write(frame);
|
|
206
|
+
} catch {}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
ping() {
|
|
210
|
+
if (this._closed) return;
|
|
211
|
+
try {
|
|
212
|
+
this.socket.write(this._encodeFrame(Buffer.alloc(0), 0x9));
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
close(code = 1000, reason = '') {
|
|
217
|
+
if (this._closed) return;
|
|
218
|
+
try {
|
|
219
|
+
const payload = Buffer.alloc(2 + reason.length);
|
|
220
|
+
payload.writeUInt16BE(code, 0);
|
|
221
|
+
payload.write(reason, 2);
|
|
222
|
+
this.socket.write(this._encodeFrame(payload, 0x8));
|
|
223
|
+
this.socket.end();
|
|
224
|
+
} catch {}
|
|
225
|
+
this._closed = true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_parseFrames() {
|
|
229
|
+
while (this._buffer.length >= 2) {
|
|
230
|
+
const opcode = this._buffer[0] & 0x0f;
|
|
231
|
+
const masked = (this._buffer[1] & 0x80) !== 0;
|
|
232
|
+
let payloadLen = this._buffer[1] & 0x7f;
|
|
233
|
+
let offset = 2;
|
|
234
|
+
|
|
235
|
+
if (payloadLen === 126) {
|
|
236
|
+
if (this._buffer.length < 4) return;
|
|
237
|
+
payloadLen = this._buffer.readUInt16BE(2);
|
|
238
|
+
offset = 4;
|
|
239
|
+
} else if (payloadLen === 127) {
|
|
240
|
+
if (this._buffer.length < 10) return;
|
|
241
|
+
payloadLen = Number(this._buffer.readBigUInt64BE(2));
|
|
242
|
+
offset = 10;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const maskLen = masked ? 4 : 0;
|
|
246
|
+
if (this._buffer.length < offset + maskLen + payloadLen) return;
|
|
247
|
+
|
|
248
|
+
const payload = this._buffer.subarray(offset + maskLen, offset + maskLen + payloadLen);
|
|
249
|
+
if (masked) {
|
|
250
|
+
const mask = this._buffer.subarray(offset, offset + 4);
|
|
251
|
+
for (let i = 0; i < payload.length; i++) {
|
|
252
|
+
payload[i] ^= mask[i % 4];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this._buffer = this._buffer.subarray(offset + maskLen + payloadLen);
|
|
257
|
+
|
|
258
|
+
switch (opcode) {
|
|
259
|
+
case 0x1: // text
|
|
260
|
+
this.emit('message', payload.toString('utf-8'));
|
|
261
|
+
break;
|
|
262
|
+
case 0x8: // close
|
|
263
|
+
this.close();
|
|
264
|
+
break;
|
|
265
|
+
case 0x9: // ping → pong
|
|
266
|
+
if (!this._closed) this.socket.write(this._encodeFrame(payload, 0xA));
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_encodeFrame(payload, opcode) {
|
|
273
|
+
const len = payload.length;
|
|
274
|
+
let header;
|
|
275
|
+
|
|
276
|
+
if (len < 126) {
|
|
277
|
+
header = Buffer.alloc(2);
|
|
278
|
+
header[0] = 0x80 | opcode;
|
|
279
|
+
header[1] = len;
|
|
280
|
+
} else if (len < 65536) {
|
|
281
|
+
header = Buffer.alloc(4);
|
|
282
|
+
header[0] = 0x80 | opcode;
|
|
283
|
+
header[1] = 126;
|
|
284
|
+
header.writeUInt16BE(len, 2);
|
|
285
|
+
} else {
|
|
286
|
+
header = Buffer.alloc(10);
|
|
287
|
+
header[0] = 0x80 | opcode;
|
|
288
|
+
header[1] = 127;
|
|
289
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return Buffer.concat([header, payload]);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Singleton ──────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
let _wss = null;
|
|
299
|
+
|
|
300
|
+
export function getWSS() {
|
|
301
|
+
if (!_wss) _wss = new WebSocketServer();
|
|
302
|
+
return _wss;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function createWSS() {
|
|
306
|
+
_wss = new WebSocketServer();
|
|
307
|
+
return _wss;
|
|
308
|
+
}
|
package/scripts/cli.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Taichu CLI — Quick Start
|
|
4
|
+
*
|
|
5
|
+
* npx taichu init → Create a new Taichu project
|
|
6
|
+
* npx taichu dev → Start development server
|
|
7
|
+
* npx taichu migrate --from=wordpress --file=dump.xml
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const cmd = process.argv[2];
|
|
15
|
+
|
|
16
|
+
switch (cmd) {
|
|
17
|
+
case 'init': {
|
|
18
|
+
const dir = process.argv[3] || 'taichu-project';
|
|
19
|
+
if (existsSync(dir)) {
|
|
20
|
+
console.log(`Directory "${dir}" already exists.`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
mkdirSync(join(dir, '.taichu', 'data'), { recursive: true });
|
|
25
|
+
mkdirSync(join(dir, 'plugins'), { recursive: true });
|
|
26
|
+
|
|
27
|
+
writeFileSync(join(dir, '.env'), `# Taichu Configuration
|
|
28
|
+
TAICHU_PORT=3120
|
|
29
|
+
TAICHU_STORAGE=sqlite
|
|
30
|
+
TAICHU_DATA_DIR=./.taichu/data
|
|
31
|
+
TAICHU_JWT_SECRET=change-me-to-a-random-string
|
|
32
|
+
TAICHU_LOG_LEVEL=info
|
|
33
|
+
`);
|
|
34
|
+
|
|
35
|
+
writeFileSync(join(dir, 'taichu.config.json'), JSON.stringify({
|
|
36
|
+
siteName: 'My Taichu Site',
|
|
37
|
+
language: 'zh-CN',
|
|
38
|
+
timezone: 'Asia/Shanghai'
|
|
39
|
+
}, null, 2));
|
|
40
|
+
|
|
41
|
+
console.log(`
|
|
42
|
+
⚡ Taichu CMS initialized in ${dir}/
|
|
43
|
+
|
|
44
|
+
To start:
|
|
45
|
+
cd ${dir}
|
|
46
|
+
npx taichu dev
|
|
47
|
+
|
|
48
|
+
Or with Docker:
|
|
49
|
+
docker run -p 3120:3120 -v $(pwd)/.taichu:/app/.taichu caludelaw/taichu
|
|
50
|
+
`);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
case 'dev': {
|
|
56
|
+
// Find taichu server module
|
|
57
|
+
const serverPath = join(import.meta.dirname || process.cwd(), '..', '..', 'packages', 'server', 'src', 'index.js');
|
|
58
|
+
if (!existsSync(serverPath)) {
|
|
59
|
+
console.error('Taichu server not found. Run from a Taichu project root.');
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const child = spawn('node', [serverPath], { stdio: 'inherit', env: { ...process.env, TAICHU_PORT: process.env.TAICHU_PORT || '3120' } });
|
|
63
|
+
child.on('exit', (code) => process.exit(code));
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case 'migrate':
|
|
68
|
+
await import('./migrate.js');
|
|
69
|
+
break;
|
|
70
|
+
|
|
71
|
+
case 'plugin':
|
|
72
|
+
await import('./plugin-cli.js');
|
|
73
|
+
break;
|
|
74
|
+
|
|
75
|
+
default:
|
|
76
|
+
console.log(`
|
|
77
|
+
⚡ Taichu CMS CLI
|
|
78
|
+
|
|
79
|
+
Commands:
|
|
80
|
+
npx taichu init [dir] Create a new Taichu project
|
|
81
|
+
npx taichu dev Start development server
|
|
82
|
+
npx taichu migrate Import content (WP/Markdown)
|
|
83
|
+
npx taichu plugin Browse & install plugins
|
|
84
|
+
|
|
85
|
+
Environment:
|
|
86
|
+
TAICHU_PORT=3120 Server port
|
|
87
|
+
TAICHU_STORAGE=sqlite Storage engine (memory/sqlite)
|
|
88
|
+
TAICHU_AGENT_KEY API key for MCP Agent
|
|
89
|
+
`);
|
|
90
|
+
}
|