@vibe-lark/larkpal 0.1.8
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/bin/larkpal.js +2 -0
- package/dist/cli.mjs +85 -0
- package/dist/interactive-init-CEVq3afY.mjs +292 -0
- package/dist/lark-cli-provider-CdgwmqSz.mjs +562 -0
- package/dist/lark-logger-D7_pEVQc.mjs +143 -0
- package/dist/main.mjs +12112 -0
- package/dist/preview-proxy-KMPQK_j4.mjs +505 -0
- package/package.json +64 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { t as larkLogger } from "./lark-logger-D7_pEVQc.mjs";
|
|
2
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { createHmac, randomBytes } from "crypto";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { Router } from "express";
|
|
8
|
+
import http from "http";
|
|
9
|
+
//#region src/gateway/plugins/preview-proxy/proxy-middleware.ts
|
|
10
|
+
/**
|
|
11
|
+
* 轻量反向代理中间件
|
|
12
|
+
*
|
|
13
|
+
* 使用 Node.js 内置 http 模块实现,无需额外依赖。
|
|
14
|
+
* 将 Express 请求代理到 localhost:{port}。
|
|
15
|
+
*
|
|
16
|
+
* 功能:
|
|
17
|
+
* - 支持 GET/POST/PUT/DELETE 等所有 HTTP 方法
|
|
18
|
+
* - 传递请求头(移除 host,添加 X-Forwarded-* 头)
|
|
19
|
+
* - 流式传输请求体和响应体
|
|
20
|
+
* - 错误处理(目标不可达时返回 502)
|
|
21
|
+
*/
|
|
22
|
+
const log$3 = larkLogger("preview/proxy");
|
|
23
|
+
/**
|
|
24
|
+
* 创建反代中间件函数
|
|
25
|
+
*
|
|
26
|
+
* @param port 目标端口(Pod 内的 localhost:{port})
|
|
27
|
+
* @param previewId 用于日志追踪
|
|
28
|
+
*/
|
|
29
|
+
function createProxyMiddleware(port, previewId) {
|
|
30
|
+
return (req, res) => {
|
|
31
|
+
const prefix = `/preview/${previewId}`;
|
|
32
|
+
let targetPath = req.originalUrl;
|
|
33
|
+
if (targetPath.startsWith(prefix)) targetPath = targetPath.slice(prefix.length) || "/";
|
|
34
|
+
const url = new URL(targetPath, `http://127.0.0.1:${port}`);
|
|
35
|
+
url.searchParams.delete("token");
|
|
36
|
+
const cleanPath = url.pathname + (url.search || "");
|
|
37
|
+
const headers = { ...req.headers };
|
|
38
|
+
delete headers.host;
|
|
39
|
+
headers["x-forwarded-for"] = req.ip || req.socket.remoteAddress || "";
|
|
40
|
+
headers["x-forwarded-proto"] = req.protocol;
|
|
41
|
+
headers["x-forwarded-host"] = req.get("host") || "";
|
|
42
|
+
headers["x-preview-id"] = previewId;
|
|
43
|
+
const proxyReq = http.request({
|
|
44
|
+
hostname: "127.0.0.1",
|
|
45
|
+
port,
|
|
46
|
+
path: cleanPath,
|
|
47
|
+
method: req.method,
|
|
48
|
+
headers
|
|
49
|
+
}, (proxyRes) => {
|
|
50
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
51
|
+
proxyRes.pipe(res);
|
|
52
|
+
});
|
|
53
|
+
proxyReq.on("error", (err) => {
|
|
54
|
+
log$3.warn("反代请求失败", {
|
|
55
|
+
previewId,
|
|
56
|
+
port,
|
|
57
|
+
targetPath: cleanPath,
|
|
58
|
+
error: err.message
|
|
59
|
+
});
|
|
60
|
+
if (!res.headersSent) res.status(502).send(`Preview target unreachable (port ${port}): ${err.message}`);
|
|
61
|
+
});
|
|
62
|
+
req.pipe(proxyReq);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/gateway/plugins/preview-proxy/token-auth.ts
|
|
67
|
+
/**
|
|
68
|
+
* Preview Token 鉴权
|
|
69
|
+
*
|
|
70
|
+
* 使用 HMAC-SHA256 生成和验证访问 Token。
|
|
71
|
+
*
|
|
72
|
+
* Token 格式:{expiresAt}.{signature}
|
|
73
|
+
* - expiresAt: Unix 毫秒时间戳
|
|
74
|
+
* - signature: HMAC-SHA256(previewId + expiresAt, secret) 的 URL-safe base64
|
|
75
|
+
*
|
|
76
|
+
* 密钥来源:
|
|
77
|
+
* - 环境变量 LARKPAL_PREVIEW_SECRET(推荐,持久化)
|
|
78
|
+
* - 未设置时:启动时随机生成 32 字节密钥(Pod 重启后旧 Token 失效)
|
|
79
|
+
*/
|
|
80
|
+
const log$2 = larkLogger("preview/token-auth");
|
|
81
|
+
/** Token 签名密钥 */
|
|
82
|
+
const SECRET = process.env.LARKPAL_PREVIEW_SECRET || randomBytes(32).toString("hex");
|
|
83
|
+
if (!process.env.LARKPAL_PREVIEW_SECRET) log$2.warn("LARKPAL_PREVIEW_SECRET 未设置,使用随机密钥(Pod 重启后旧 Token 失效)");
|
|
84
|
+
/** 默认 Token 有效期:24 小时 */
|
|
85
|
+
const DEFAULT_TTL_MS = 1440 * 60 * 1e3;
|
|
86
|
+
/**
|
|
87
|
+
* 生成访问 Token
|
|
88
|
+
*/
|
|
89
|
+
function generateToken(previewId, ttlMs = DEFAULT_TTL_MS) {
|
|
90
|
+
const expiresAt = Date.now() + ttlMs;
|
|
91
|
+
const token = `${expiresAt}.${sign(previewId, expiresAt)}`;
|
|
92
|
+
log$2.info("生成 Preview Token", {
|
|
93
|
+
previewId,
|
|
94
|
+
expiresAt: new Date(expiresAt).toISOString()
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
token,
|
|
98
|
+
expiresAt
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 验证访问 Token
|
|
103
|
+
*
|
|
104
|
+
* @returns true 如果 Token 有效且未过期
|
|
105
|
+
*/
|
|
106
|
+
function verifyToken(previewId, token) {
|
|
107
|
+
const dotIndex = token.indexOf(".");
|
|
108
|
+
if (dotIndex === -1) return false;
|
|
109
|
+
const expiresAtStr = token.substring(0, dotIndex);
|
|
110
|
+
const signature = token.substring(dotIndex + 1);
|
|
111
|
+
const expiresAt = Number(expiresAtStr);
|
|
112
|
+
if (isNaN(expiresAt)) return false;
|
|
113
|
+
if (Date.now() > expiresAt) {
|
|
114
|
+
log$2.debug("Token 已过期", {
|
|
115
|
+
previewId,
|
|
116
|
+
expiresAt: new Date(expiresAt).toISOString()
|
|
117
|
+
});
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (signature !== sign(previewId, expiresAt)) {
|
|
121
|
+
log$2.debug("Token 签名不匹配", { previewId });
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
/** HMAC-SHA256 签名 */
|
|
127
|
+
function sign(previewId, expiresAt) {
|
|
128
|
+
const data = `${previewId}:${expiresAt}`;
|
|
129
|
+
return createHmac("sha256", SECRET).update(data).digest("base64url");
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/gateway/plugins/preview-proxy/preview-manager.ts
|
|
133
|
+
/**
|
|
134
|
+
* Preview 实例管理器
|
|
135
|
+
*
|
|
136
|
+
* 管理所有活跃的 Preview 实例:
|
|
137
|
+
* - 创建 / 查询 / 删除
|
|
138
|
+
* - 按 sessionId 批量回收
|
|
139
|
+
* - 定期清理过期实例
|
|
140
|
+
*/
|
|
141
|
+
const log$1 = larkLogger("preview/manager");
|
|
142
|
+
/** 默认过期时间:24 小时 */
|
|
143
|
+
const DEFAULT_EXPIRES_MS = 1440 * 60 * 1e3;
|
|
144
|
+
/** 清理间隔:10 分钟 */
|
|
145
|
+
const CLEANUP_INTERVAL_MS = 600 * 1e3;
|
|
146
|
+
var PreviewManager = class {
|
|
147
|
+
/** previewId → PreviewInstance */
|
|
148
|
+
previews = /* @__PURE__ */ new Map();
|
|
149
|
+
/** 定期清理定时器 */
|
|
150
|
+
cleanupTimer = null;
|
|
151
|
+
/** 外部基础 URL */
|
|
152
|
+
externalBaseUrl;
|
|
153
|
+
constructor(externalBaseUrl) {
|
|
154
|
+
this.externalBaseUrl = externalBaseUrl || "";
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* 创建 Preview 实例
|
|
158
|
+
*/
|
|
159
|
+
create(request) {
|
|
160
|
+
const previewId = this.generateId();
|
|
161
|
+
const visibility = request.visibility || "private";
|
|
162
|
+
let token;
|
|
163
|
+
let expiresAt;
|
|
164
|
+
if (visibility === "private") {
|
|
165
|
+
const tokenResult = generateToken(previewId);
|
|
166
|
+
token = tokenResult.token;
|
|
167
|
+
expiresAt = tokenResult.expiresAt;
|
|
168
|
+
} else expiresAt = Date.now() + DEFAULT_EXPIRES_MS;
|
|
169
|
+
const baseUrl = this.externalBaseUrl || `http://localhost:${process.env.PORT || 3e3}`;
|
|
170
|
+
let url = `${baseUrl}/preview/${previewId}`;
|
|
171
|
+
if (visibility === "private" && token) url += `?token=${encodeURIComponent(token)}`;
|
|
172
|
+
const instance = {
|
|
173
|
+
previewId,
|
|
174
|
+
port: request.port,
|
|
175
|
+
visibility,
|
|
176
|
+
sessionId: request.sessionId,
|
|
177
|
+
createdAt: Date.now(),
|
|
178
|
+
expiresAt,
|
|
179
|
+
token,
|
|
180
|
+
url
|
|
181
|
+
};
|
|
182
|
+
this.previews.set(previewId, instance);
|
|
183
|
+
log$1.info("Preview 已创建", {
|
|
184
|
+
previewId,
|
|
185
|
+
port: request.port,
|
|
186
|
+
visibility,
|
|
187
|
+
sessionId: request.sessionId,
|
|
188
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
189
|
+
url: visibility === "public" ? url : `${baseUrl}/preview/${previewId}?token=***`
|
|
190
|
+
});
|
|
191
|
+
return {
|
|
192
|
+
previewId,
|
|
193
|
+
url,
|
|
194
|
+
token,
|
|
195
|
+
visibility,
|
|
196
|
+
expiresAt: new Date(expiresAt).toISOString()
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 根据 previewId 获取实例
|
|
201
|
+
*/
|
|
202
|
+
get(previewId) {
|
|
203
|
+
return this.previews.get(previewId);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 删除指定 Preview
|
|
207
|
+
*/
|
|
208
|
+
delete(previewId) {
|
|
209
|
+
const existed = this.previews.delete(previewId);
|
|
210
|
+
if (existed) log$1.info("Preview 已删除", { previewId });
|
|
211
|
+
return existed;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* 按 sessionId 批量回收
|
|
215
|
+
*/
|
|
216
|
+
deleteBySession(sessionId) {
|
|
217
|
+
let count = 0;
|
|
218
|
+
for (const [id, instance] of this.previews) if (instance.sessionId === sessionId) {
|
|
219
|
+
this.previews.delete(id);
|
|
220
|
+
count++;
|
|
221
|
+
}
|
|
222
|
+
if (count > 0) log$1.info("按会话批量回收 Preview", {
|
|
223
|
+
sessionId,
|
|
224
|
+
count
|
|
225
|
+
});
|
|
226
|
+
return count;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* 列出所有活跃 Preview
|
|
230
|
+
*/
|
|
231
|
+
list() {
|
|
232
|
+
return Array.from(this.previews.values());
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* 启动定期清理任务
|
|
236
|
+
*/
|
|
237
|
+
startCleanup() {
|
|
238
|
+
this.cleanupTimer = setInterval(() => {
|
|
239
|
+
this.cleanupExpired();
|
|
240
|
+
}, CLEANUP_INTERVAL_MS);
|
|
241
|
+
log$1.info("Preview 定期清理已启动", { intervalMs: CLEANUP_INTERVAL_MS });
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* 停止清理任务并回收所有 Preview
|
|
245
|
+
*/
|
|
246
|
+
dispose() {
|
|
247
|
+
if (this.cleanupTimer) {
|
|
248
|
+
clearInterval(this.cleanupTimer);
|
|
249
|
+
this.cleanupTimer = null;
|
|
250
|
+
}
|
|
251
|
+
const count = this.previews.size;
|
|
252
|
+
this.previews.clear();
|
|
253
|
+
log$1.info("PreviewManager 已清理", { clearedCount: count });
|
|
254
|
+
}
|
|
255
|
+
/** 清理过期实例 */
|
|
256
|
+
cleanupExpired() {
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
let cleaned = 0;
|
|
259
|
+
for (const [id, instance] of this.previews) if (instance.expiresAt < now) {
|
|
260
|
+
this.previews.delete(id);
|
|
261
|
+
cleaned++;
|
|
262
|
+
}
|
|
263
|
+
if (cleaned > 0) log$1.info("清理过期 Preview", {
|
|
264
|
+
cleaned,
|
|
265
|
+
remaining: this.previews.size
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/** 生成 12 字符的随机 ID */
|
|
269
|
+
generateId() {
|
|
270
|
+
return randomBytes(9).toString("base64url");
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/gateway/plugins/preview-proxy/index.ts
|
|
275
|
+
/**
|
|
276
|
+
* Preview 插件 — preview-proxy
|
|
277
|
+
*
|
|
278
|
+
* 将 Pod 内 dev server 反代到公网可访问的 URL。
|
|
279
|
+
* 支持 private(Token 鉴权)和 public(无限制)两种权限。
|
|
280
|
+
*
|
|
281
|
+
* 路由:
|
|
282
|
+
* POST /api/previews 创建 preview
|
|
283
|
+
* GET /api/previews 列出活跃 previews
|
|
284
|
+
* DELETE /api/previews/:previewId 删除 preview
|
|
285
|
+
* ALL /preview/:previewId/* 反代到 localhost:{port}
|
|
286
|
+
*/
|
|
287
|
+
const log = larkLogger("plugin/preview-proxy");
|
|
288
|
+
/**
|
|
289
|
+
* Preview 插件工厂函数
|
|
290
|
+
*/
|
|
291
|
+
function createPreviewProxyPlugin() {
|
|
292
|
+
let manager;
|
|
293
|
+
return {
|
|
294
|
+
name: "preview-proxy",
|
|
295
|
+
version: "0.1.0",
|
|
296
|
+
description: "将 Pod 内 dev server 反代到公网可访问的 URL,支持 private/public 权限",
|
|
297
|
+
requiresExternalAccess: true,
|
|
298
|
+
register(app, context) {
|
|
299
|
+
manager = new PreviewManager(context.externalBaseUrl);
|
|
300
|
+
const apiRouter = Router();
|
|
301
|
+
apiRouter.post("/api/previews", (req, res) => {
|
|
302
|
+
try {
|
|
303
|
+
const body = req.body;
|
|
304
|
+
if (!body.port || !body.sessionId) {
|
|
305
|
+
res.status(400).json({ error: "缺少必要参数: port, sessionId" });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (body.port < 1 || body.port > 65535) {
|
|
309
|
+
res.status(400).json({ error: "端口范围无效: 1-65535" });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const result = manager.create(body);
|
|
313
|
+
log.info("API: 创建 Preview 成功", {
|
|
314
|
+
previewId: result.previewId,
|
|
315
|
+
port: body.port,
|
|
316
|
+
visibility: result.visibility
|
|
317
|
+
});
|
|
318
|
+
res.status(201).json(result);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
log.error("API: 创建 Preview 失败", { error: err instanceof Error ? err.message : String(err) });
|
|
321
|
+
res.status(500).json({ error: "创建 Preview 失败" });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
apiRouter.get("/api/previews", (_req, res) => {
|
|
325
|
+
const list = manager.list().map((p) => ({
|
|
326
|
+
previewId: p.previewId,
|
|
327
|
+
port: p.port,
|
|
328
|
+
visibility: p.visibility,
|
|
329
|
+
sessionId: p.sessionId,
|
|
330
|
+
url: p.visibility === "public" ? p.url : p.url.replace(/\?token=.*/, "?token=***"),
|
|
331
|
+
createdAt: new Date(p.createdAt).toISOString(),
|
|
332
|
+
expiresAt: new Date(p.expiresAt).toISOString()
|
|
333
|
+
}));
|
|
334
|
+
res.json({
|
|
335
|
+
previews: list,
|
|
336
|
+
total: list.length
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
apiRouter.delete("/api/previews/:previewId", (req, res) => {
|
|
340
|
+
const previewId = String(req.params.previewId);
|
|
341
|
+
if (manager.delete(previewId)) {
|
|
342
|
+
log.info("API: 删除 Preview", { previewId });
|
|
343
|
+
res.json({ deleted: true });
|
|
344
|
+
} else res.status(404).json({ error: "Preview 不存在" });
|
|
345
|
+
});
|
|
346
|
+
apiRouter.delete("/api/previews/session/:sessionId", (req, res) => {
|
|
347
|
+
const sessionId = String(req.params.sessionId);
|
|
348
|
+
const count = manager.deleteBySession(sessionId);
|
|
349
|
+
log.info("API: 按会话回收 Preview", {
|
|
350
|
+
sessionId,
|
|
351
|
+
count
|
|
352
|
+
});
|
|
353
|
+
res.json({ deleted: count });
|
|
354
|
+
});
|
|
355
|
+
app.use(apiRouter);
|
|
356
|
+
app.all("/preview/:previewId", handlePreviewRequest);
|
|
357
|
+
app.all("/preview/:previewId/*splat", handlePreviewRequest);
|
|
358
|
+
/**
|
|
359
|
+
* Preview 反代请求处理
|
|
360
|
+
*/
|
|
361
|
+
function handlePreviewRequest(req, res) {
|
|
362
|
+
const previewId = String(req.params.previewId);
|
|
363
|
+
const instance = manager.get(previewId);
|
|
364
|
+
if (!instance) {
|
|
365
|
+
res.status(404).send("Preview not found or expired");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (Date.now() > instance.expiresAt) {
|
|
369
|
+
manager.delete(previewId);
|
|
370
|
+
res.status(410).send("Preview expired");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (instance.visibility === "private") {
|
|
374
|
+
const token = req.query.token || "";
|
|
375
|
+
if (!token || !verifyToken(previewId, token)) {
|
|
376
|
+
log.debug("Preview Token 验证失败", { previewId });
|
|
377
|
+
res.status(403).send("Access denied: invalid or expired token");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
createProxyMiddleware(instance.port, previewId)(req, res);
|
|
382
|
+
}
|
|
383
|
+
log.info("Preview 插件路由已注册", { routes: [
|
|
384
|
+
"POST /api/previews",
|
|
385
|
+
"GET /api/previews",
|
|
386
|
+
"DELETE /api/previews/:previewId",
|
|
387
|
+
"DELETE /api/previews/session/:sessionId",
|
|
388
|
+
"ALL /preview/:previewId/*"
|
|
389
|
+
] });
|
|
390
|
+
},
|
|
391
|
+
async init() {
|
|
392
|
+
manager.startCleanup();
|
|
393
|
+
await installPreviewSkill();
|
|
394
|
+
log.info("Preview 插件初始化完成");
|
|
395
|
+
},
|
|
396
|
+
async dispose() {
|
|
397
|
+
manager.dispose();
|
|
398
|
+
log.info("Preview 插件已关闭");
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/** 技能文件路径 */
|
|
403
|
+
const SKILL_PATH = join(homedir(), ".claude", "commands", "preview.md");
|
|
404
|
+
/** 技能版本标记(更新技能内容时递增,触发覆盖旧版本) */
|
|
405
|
+
const SKILL_VERSION = "v0.1.1";
|
|
406
|
+
/**
|
|
407
|
+
* 安装 /preview 技能文件
|
|
408
|
+
*
|
|
409
|
+
* 幂等操作:仅在文件不存在或版本不匹配时写入。
|
|
410
|
+
*/
|
|
411
|
+
async function installPreviewSkill() {
|
|
412
|
+
await mkdir(join(homedir(), ".claude", "commands"), { recursive: true });
|
|
413
|
+
if (existsSync(SKILL_PATH)) {
|
|
414
|
+
const { readFile } = await import("fs/promises");
|
|
415
|
+
if ((await readFile(SKILL_PATH, "utf-8")).includes(`skill-version: ${SKILL_VERSION}`)) {
|
|
416
|
+
log.debug("Preview 技能已是最新版本,跳过安装");
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
await writeFile(SKILL_PATH, PREVIEW_SKILL_CONTENT, "utf-8");
|
|
421
|
+
log.info("Preview 配套技能已安装", {
|
|
422
|
+
path: SKILL_PATH,
|
|
423
|
+
version: SKILL_VERSION
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* /preview 技能内容
|
|
428
|
+
*
|
|
429
|
+
* 教会 CC 如何启动 dev server 并通过网关 API 注册 Preview,
|
|
430
|
+
* 将公网可访问的链接返回给用户。
|
|
431
|
+
*/
|
|
432
|
+
const PREVIEW_SKILL_CONTENT = `---
|
|
433
|
+
skill-version: ${SKILL_VERSION}
|
|
434
|
+
description: 启动 dev server 并生成公网可访问的预览链接
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
# Preview — 公网预览
|
|
438
|
+
|
|
439
|
+
当用户要求预览一个 Web 应用(网站、页面等)时,按以下步骤操作:
|
|
440
|
+
|
|
441
|
+
## 步骤
|
|
442
|
+
|
|
443
|
+
### 1. 启动 dev server
|
|
444
|
+
|
|
445
|
+
根据项目类型启动对应的 dev server,确保监听 \`127.0.0.1\`:
|
|
446
|
+
|
|
447
|
+
- **Vite/React/Vue**: \`npm run dev -- --host 127.0.0.1\`
|
|
448
|
+
- **Next.js**: \`npm run dev -- -H 127.0.0.1\`
|
|
449
|
+
- **静态文件**: \`npx serve -l 5173 .\`
|
|
450
|
+
- **其他**: 确保 dev server 监听在某个本地端口
|
|
451
|
+
|
|
452
|
+
记下实际监听的端口号(通常是 5173、3000、3001 等)。
|
|
453
|
+
|
|
454
|
+
### 2. 注册 Preview
|
|
455
|
+
|
|
456
|
+
调用网关 API 创建 Preview:
|
|
457
|
+
|
|
458
|
+
\`\`\`bash
|
|
459
|
+
curl -s -X POST http://localhost:3000/api/previews \\
|
|
460
|
+
-H 'Content-Type: application/json' \\
|
|
461
|
+
-d '{
|
|
462
|
+
"port": <实际端口号>,
|
|
463
|
+
"sessionId": "<当前会话ID>",
|
|
464
|
+
"visibility": "private"
|
|
465
|
+
}'
|
|
466
|
+
\`\`\`
|
|
467
|
+
|
|
468
|
+
参数说明:
|
|
469
|
+
- \`port\`: dev server 监听的端口号
|
|
470
|
+
- \`sessionId\`: 使用环境变量 \`$LARKPAL_SESSION_ID\` 或当前工作目录名
|
|
471
|
+
- \`visibility\`: \`"private"\`(仅创建者可访问,需要 token)或 \`"public"\`(任何人可访问)
|
|
472
|
+
|
|
473
|
+
### 3. 返回链接
|
|
474
|
+
|
|
475
|
+
API 会返回:
|
|
476
|
+
\`\`\`json
|
|
477
|
+
{
|
|
478
|
+
"previewId": "xxx",
|
|
479
|
+
"url": "https://larkpal.example.com/preview/xxx?token=yyy",
|
|
480
|
+
"visibility": "private",
|
|
481
|
+
"expiresAt": "2025-01-01T00:00:00.000Z"
|
|
482
|
+
}
|
|
483
|
+
\`\`\`
|
|
484
|
+
|
|
485
|
+
将 \`url\` 返回给用户。如果是 private 模式,提醒用户此链接仅本人可访问。
|
|
486
|
+
|
|
487
|
+
**重要:输出给用户的地址汇总中,必须使用 API 返回的公网 \`url\`,不要使用 \`localhost\` 或 \`127.0.0.1\` 地址。用户无法访问 Pod 内部的本地端口。**
|
|
488
|
+
|
|
489
|
+
### 4. 关闭 Preview(可选)
|
|
490
|
+
|
|
491
|
+
完成预览后,可以关闭:
|
|
492
|
+
|
|
493
|
+
\`\`\`bash
|
|
494
|
+
curl -s -X DELETE http://localhost:3000/api/previews/<previewId>
|
|
495
|
+
\`\`\`
|
|
496
|
+
|
|
497
|
+
## 注意事项
|
|
498
|
+
|
|
499
|
+
- dev server 必须监听 \`127.0.0.1\`,不要监听 \`0.0.0.0\`(安全要求)
|
|
500
|
+
- Preview 默认 24 小时后过期
|
|
501
|
+
- 如果用户说"分享给别人看",使用 \`"visibility": "public"\`
|
|
502
|
+
- 如果用户只是自己看,使用 \`"visibility": "private"\`(默认)
|
|
503
|
+
`;
|
|
504
|
+
//#endregion
|
|
505
|
+
export { createPreviewProxyPlugin };
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vibe-lark/larkpal",
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"description": "LarkPal - Lark/Feishu bot service",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/main.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"larkpal": "bin/larkpal.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"dist/"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/vibe-lark/larkpal.git"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"registry": "https://registry.npmjs.org",
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"packageManager": "pnpm@10.32.1",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"start": "node --env-file=.env bin/larkpal.js",
|
|
28
|
+
"build": "tsdown",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"lint": "eslint src/",
|
|
32
|
+
"lint:fix": "eslint src/ --fix",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"format": "prettier --write src/**/*.ts",
|
|
35
|
+
"format:check": "prettier --check src/**/*.ts"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@larksuiteoapi/node-sdk": "^1.60.0",
|
|
39
|
+
"@sinclair/typebox": "0.34.48",
|
|
40
|
+
"dotenv": "^17.4.2",
|
|
41
|
+
"express": "^5.1.0",
|
|
42
|
+
"image-size": "^2.0.2",
|
|
43
|
+
"node-cron": "^4.0.8",
|
|
44
|
+
"openclaw": "^2026.4.9",
|
|
45
|
+
"uuid": "^11.1.0",
|
|
46
|
+
"zod": "^4.3.6"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@eslint/js": "^10.0.1",
|
|
50
|
+
"@types/express": "^5.0.2",
|
|
51
|
+
"@types/node": "^25.2.3",
|
|
52
|
+
"@types/node-cron": "^3.0.11",
|
|
53
|
+
"@types/uuid": "^10.0.0",
|
|
54
|
+
"eslint": "^9.39.3",
|
|
55
|
+
"eslint-plugin-import-x": "^4.16.2",
|
|
56
|
+
"eslint-plugin-n": "^17.24.0",
|
|
57
|
+
"globals": "^16.2.0",
|
|
58
|
+
"prettier": "^3.8.1",
|
|
59
|
+
"tsdown": "^0.21.4",
|
|
60
|
+
"typescript": "^5.9.3",
|
|
61
|
+
"typescript-eslint": "^8.32.1",
|
|
62
|
+
"vitest": "^4.1.1"
|
|
63
|
+
}
|
|
64
|
+
}
|