cardbuddy 0.7.0 → 0.9.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/README.md +21 -0
- package/dist/cli.js +5 -0
- package/dist/preview-editor/assets/index-CrcrVs4F.css +1 -0
- package/dist/preview-editor/assets/index-DnpHHKWl.js +80 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/server-core/codeBridge.js +359 -0
- package/dist/server-core/index.js +27 -2
- package/dist/server-core/logStream.js +275 -0
- package/dist/standalone.js +33 -6
- package/dist/vendored/App.vue +28 -19
- package/dist/vendored/ansi.js +185 -0
- package/dist/vendored/api.js +164 -28
- package/dist/vendored/components/ApiCallPanel.vue +13 -1
- package/dist/vendored/components/ApiPayloadTree.vue +34 -1
- package/dist/vendored/components/CardGallery.vue +2 -2
- package/dist/vendored/components/ComponentPalette.vue +46 -39
- package/dist/vendored/components/ConfirmDialog.vue +2 -2
- package/dist/vendored/components/DebugPanel.vue +420 -69
- package/dist/vendored/components/EventEditor.vue +29 -3
- package/dist/vendored/components/LogValue.vue +2 -1
- package/dist/vendored/components/NodeInspector.vue +1 -1
- package/dist/vendored/components/PropControl.vue +6 -2
- package/dist/vendored/components/RemotePreview.vue +3 -1
- package/dist/vendored/components/StructureTree.vue +2 -0
- package/dist/vendored/components/setters/SetCode.vue +111 -3
- package/dist/vendored/styles/app.css +529 -96
- package/dist/vendored/styles/card-gallery.css +4 -11
- package/dist/vendored/styles/card-select.css +0 -15
- package/dist/vendored/styles/dropdown.css +3 -15
- package/dist/vendored/styles/json-tree.css +1 -1
- package/dist/vendored/styles/node-inspector.css +204 -131
- package/dist/vendored/styles/setters.css +91 -2
- package/dist/vendored/styles/structure-tree.css +27 -12
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-BlscexDE.js +0 -79
- package/dist/preview-editor/assets/index-dlbSPb5W.css +0 -1
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16-Cg6Nrosk.png" />
|
|
9
9
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32-B_1744sp.png" />
|
|
10
10
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-BQcES9QK.png" />
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-DnpHHKWl.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CrcrVs4F.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="app"></div>
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { log } from './logger.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 外部编辑器代码桥(VSCode / DSL_EDITOR_CMD 指定的任意编辑器,如 cursor)。
|
|
10
|
+
*
|
|
11
|
+
* 闭环:
|
|
12
|
+
* 1) 前端 POST /__code-bridge { code, label } → 后端在 os.tmpdir()/cardbuddy-code-bridge/
|
|
13
|
+
* 下写 <session>.js 并拉起编辑器,返回 { id, file };
|
|
14
|
+
* 2) 后端 chokidar 单文件监听:保存事件防抖 + 内容哈希去重(忽略服务端自写回声),
|
|
15
|
+
* 有实质变化经 SSE 推 code-bridge-changed { id }(内容走 GET 拉取,避免大 payload);
|
|
16
|
+
* 3) 前端 GET /__code-bridge/<id> 取回代码写回 CodeJar(updateCode 不触发 onUpdate,断回声环);
|
|
17
|
+
* 4) 会话结束 = 前端 DELETE(面板关闭 / 手动断开 / 组件卸载)或心跳 TTL 兜底
|
|
18
|
+
* (前端 5s 心跳,20s 无心跳即删文件,覆盖关浏览器 / 崩溃);启动时清扫 >24h 残留。
|
|
19
|
+
*
|
|
20
|
+
* 「关闭」语义说明:VSCode CLI 把文件交给已运行实例后立即返回,标签页关闭事件不装扩展
|
|
21
|
+
* 拿不到,故清除时机取「会话结束 + TTL」,而非 VSCode 关标签瞬间。
|
|
22
|
+
*
|
|
23
|
+
* 安全:与其余 /__ 端点同信任级(仅 127.0.0.1 + 可选 DSL_DEV_TOKEN);临时文件名由服务端
|
|
24
|
+
* 生成(hex 白名单),用户代码内容不进入命令行;DSL_EDITOR_CMD 为开发者自配环境变量,
|
|
25
|
+
* 按 shell 语义执行(文档注明)。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const BRIDGE_DIR_NAME = 'cardbuddy-code-bridge'
|
|
29
|
+
export const SESSION_ID_RE = /^[a-f0-9]{8}$/
|
|
30
|
+
export const HEARTBEAT_TTL_MS = 20000
|
|
31
|
+
const REAP_INTERVAL_MS = 5000
|
|
32
|
+
const WATCH_DEBOUNCE_MS = 150
|
|
33
|
+
const STALE_FILE_MS = 24 * 60 * 60 * 1000
|
|
34
|
+
const MAX_BODY = 2 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
/** 桥接临时目录:os.tmpdir() 下独立子目录,不污染仓库 */
|
|
37
|
+
export function bridgeDir() {
|
|
38
|
+
return path.join(os.tmpdir(), BRIDGE_DIR_NAME)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 回写归一:Windows 编辑器可能以 CRLF 落盘,schema 代码串统一 LF,避免无谓 churn */
|
|
42
|
+
export function normalizeEol(s) {
|
|
43
|
+
if (s == null) return ''
|
|
44
|
+
return String(s).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function hashContent(s) {
|
|
48
|
+
return crypto.createHash('sha1').update(String(s), 'utf8').digest('hex')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 编辑器拉起方式(纯函数,便于单测):
|
|
53
|
+
* - DSL_EDITOR_CMD 优先:整串按 shell 语义执行 + 追加带引号文件路径(支持 cursor / 自定义命令带参数);
|
|
54
|
+
* - 否则平台默认 VSCode:mac 走 `open -a`;linux 直接 `code`;win 走 shell 语义的 `code "<file>"`
|
|
55
|
+
* (PATH 里的 code 是 code.cmd,裸 spawn 执行不了 .cmd;而 ComSpec + 参数数组会被 node 的
|
|
56
|
+
* Windows 引号转义与 cmd /s /c 剥首尾引号规则叠加拆坏,实测命令变成 '\"code\"' 报 9009);
|
|
57
|
+
* - 返回 null 表示平台不支持(由 resolveEditorChain 继续降级 vscode:// 协议)。
|
|
58
|
+
*/
|
|
59
|
+
export function resolveEditorSpec(file, env = process.env, platform = process.platform) {
|
|
60
|
+
const custom = String(env.DSL_EDITOR_CMD || '').trim()
|
|
61
|
+
if (custom) return { kind: 'custom', command: `${custom} "${file}"`, shell: true }
|
|
62
|
+
if (platform === 'darwin') {
|
|
63
|
+
return { kind: 'vscode', cmd: 'open', args: ['-a', 'Visual Studio Code', file], shell: false }
|
|
64
|
+
}
|
|
65
|
+
if (platform === 'win32') {
|
|
66
|
+
return { kind: 'vscode', command: `code "${file}"`, shell: true }
|
|
67
|
+
}
|
|
68
|
+
if (platform === 'linux') {
|
|
69
|
+
return { kind: 'vscode', cmd: 'code', args: [file], shell: false }
|
|
70
|
+
}
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** VSCode 未安装 / CLI 缺失时的协议兜底(安装器默认注册 vscode:// 处理器) */
|
|
75
|
+
export function resolveProtocolSpec(file, env = process.env, platform = process.platform) {
|
|
76
|
+
const url = 'vscode://file/' + String(file).split(path.sep).join('/')
|
|
77
|
+
if (platform === 'darwin') return { cmd: 'open', args: [url], shell: false }
|
|
78
|
+
if (platform === 'win32') {
|
|
79
|
+
return { command: `start "" "${url}"`, shell: true }
|
|
80
|
+
}
|
|
81
|
+
if (platform === 'linux') return { cmd: 'xdg-open', args: [url], shell: false }
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 拉起方式降级链(跨平台兼容的核心):
|
|
87
|
+
* - DSL_EDITOR_CMD 自定义 → 单级;
|
|
88
|
+
* - win:`code "<file>"`(shell 语义)→ vscode:// 协议;
|
|
89
|
+
* - mac:`open -a "Visual Studio Code"` → `code` CLI(App 被改名/移动但装过 CLI 时兜一层)→ vscode:// 协议;
|
|
90
|
+
* - linux:`code` → xdg-open vscode://;
|
|
91
|
+
* 任一级 spawn ENOENT 或非 0 退出即尝试下一级。
|
|
92
|
+
*/
|
|
93
|
+
export function resolveEditorChain(file, env = process.env, platform = process.platform) {
|
|
94
|
+
const custom = String(env.DSL_EDITOR_CMD || '').trim()
|
|
95
|
+
if (custom) return [{ tag: 'custom', spec: { command: `${custom} "${file}"`, shell: true } }]
|
|
96
|
+
const chain = []
|
|
97
|
+
const primary = resolveEditorSpec(file, env, platform)
|
|
98
|
+
if (primary) chain.push({ tag: primary.kind, spec: primary })
|
|
99
|
+
if (platform === 'darwin') chain.push({ tag: 'vscode-cli', spec: { command: `code "${file}"`, shell: true } })
|
|
100
|
+
const proto = resolveProtocolSpec(file, env, platform)
|
|
101
|
+
if (proto) chain.push({ tag: 'protocol', spec: proto })
|
|
102
|
+
return chain
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function launchEditor(file) {
|
|
106
|
+
const chain = resolveEditorChain(file)
|
|
107
|
+
const attempt = (i) => {
|
|
108
|
+
if (i >= chain.length) return false
|
|
109
|
+
const { tag, spec } = chain[i]
|
|
110
|
+
const next = () => {
|
|
111
|
+
if (i + 1 >= chain.length) return false
|
|
112
|
+
if (!attempt(i + 1)) return false
|
|
113
|
+
log.ok('代码桥降级下一级拉起方式', { from: tag, to: chain[i + 1].tag, file })
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const child = spec.shell
|
|
118
|
+
? spawn(spec.command, { shell: true, stdio: 'ignore' })
|
|
119
|
+
: spawn(spec.cmd, spec.args, { shell: false, stdio: 'ignore' })
|
|
120
|
+
let handed = false
|
|
121
|
+
const fail = (detail) => {
|
|
122
|
+
if (handed) return
|
|
123
|
+
handed = true
|
|
124
|
+
log.warn(`代码桥拉起编辑器失败(${tag})`, detail)
|
|
125
|
+
next()
|
|
126
|
+
}
|
|
127
|
+
child.on('error', (e) => fail({ message: String(e.message) }))
|
|
128
|
+
// shell 语义下 spawn 本身不会 ENOENT(shell 一定在),内部命令缺失只体现在退出码上
|
|
129
|
+
child.on('exit', (code) => {
|
|
130
|
+
if (code === 0 || code === null) return
|
|
131
|
+
fail({ code })
|
|
132
|
+
})
|
|
133
|
+
return true
|
|
134
|
+
} catch (e) {
|
|
135
|
+
log.warn(`代码桥拉起编辑器异常(${tag})`, e)
|
|
136
|
+
return next()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return attempt(0)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 启动清扫:删除 >24h 的残留临时文件(会话表在内存,跨重启即孤儿) */
|
|
143
|
+
export function sweepStaleFiles(now = Date.now()) {
|
|
144
|
+
const dir = bridgeDir()
|
|
145
|
+
let removed = 0
|
|
146
|
+
try {
|
|
147
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
148
|
+
for (const name of fs.readdirSync(dir)) {
|
|
149
|
+
if (!SESSION_ID_RE.test(name.replace(/\.js$/, ''))) continue
|
|
150
|
+
const full = path.join(dir, name)
|
|
151
|
+
try {
|
|
152
|
+
const st = fs.statSync(full)
|
|
153
|
+
if (now - st.mtimeMs > STALE_FILE_MS) {
|
|
154
|
+
fs.rmSync(full, { force: true })
|
|
155
|
+
removed++
|
|
156
|
+
}
|
|
157
|
+
} catch (_) { /* 忽略单文件竞态 */ }
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
log.warn('代码桥启动清扫失败', { message: String(e.message) })
|
|
161
|
+
}
|
|
162
|
+
return removed
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 默认单文件监听(fs.watch + rename 重挂兜底原子保存)。
|
|
167
|
+
* cardbuddy 会注入 chokidar 版 watchFile(更稳);本兜底保证中间件零额外依赖,
|
|
168
|
+
* 使 server/core 可被根仓库(无 chokidar 依赖)直接 import / 单测。
|
|
169
|
+
*/
|
|
170
|
+
export function defaultWatchFile(file, onChange) {
|
|
171
|
+
let watcher = null
|
|
172
|
+
let rearmTimer = null
|
|
173
|
+
let closed = false
|
|
174
|
+
const arm = () => {
|
|
175
|
+
if (closed) return
|
|
176
|
+
try {
|
|
177
|
+
watcher = fs.watch(file, (event) => {
|
|
178
|
+
onChange()
|
|
179
|
+
// 原子保存(临时文件改名覆盖)会让旧 inode 失效:重挂监听
|
|
180
|
+
if (event === 'rename') rearm()
|
|
181
|
+
})
|
|
182
|
+
watcher.on('error', () => rearm())
|
|
183
|
+
} catch (_) {
|
|
184
|
+
rearm()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const rearm = () => {
|
|
188
|
+
if (closed) return
|
|
189
|
+
try {
|
|
190
|
+
watcher && watcher.close()
|
|
191
|
+
} catch (_) { /* ignore */ }
|
|
192
|
+
if (rearmTimer) clearTimeout(rearmTimer)
|
|
193
|
+
rearmTimer = setTimeout(arm, 120)
|
|
194
|
+
}
|
|
195
|
+
arm()
|
|
196
|
+
return {
|
|
197
|
+
close() {
|
|
198
|
+
closed = true
|
|
199
|
+
if (rearmTimer) clearTimeout(rearmTimer)
|
|
200
|
+
try {
|
|
201
|
+
watcher && watcher.close()
|
|
202
|
+
} catch (_) { /* ignore */ }
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @param {{ root?: string, notify?: (data: object, event?: string) => void, watchFile?: (file: string, cb: () => void) => { close: () => void } }} opts
|
|
209
|
+
* - watchFile:单文件监听能力注入(cardbuddy 注入 chokidar 版);缺省用 fs.watch 兜底。
|
|
210
|
+
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
211
|
+
*/
|
|
212
|
+
export function createCodeBridgeMiddleware({ notify = null, watchFile = null } = {}) {
|
|
213
|
+
/** @type {Map<string, {id:string,file:string,label:string,lastHash:string,lastHeartbeat:number,watcher:object|null,timer:object|null}>} */
|
|
214
|
+
const sessions = new Map()
|
|
215
|
+
const swept = sweepStaleFiles()
|
|
216
|
+
if (swept) log.ok('代码桥清扫残留临时文件', { removed: swept })
|
|
217
|
+
|
|
218
|
+
const push = (data, event) => {
|
|
219
|
+
if (typeof notify === 'function') notify(data, event)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function closeSession(id, reason) {
|
|
223
|
+
const s = sessions.get(id)
|
|
224
|
+
if (!s) return false
|
|
225
|
+
sessions.delete(id)
|
|
226
|
+
if (s.timer) clearTimeout(s.timer)
|
|
227
|
+
try { s.watcher && s.watcher.close() } catch (_) { /* ignore */ }
|
|
228
|
+
try { fs.rmSync(s.file, { force: true }) } catch (e) {
|
|
229
|
+
log.warn('代码桥删除临时文件失败', { file: s.file, message: String(e.message) })
|
|
230
|
+
}
|
|
231
|
+
push({ id, reason }, 'code-bridge-closed')
|
|
232
|
+
log.ok('代码桥会话结束', { id, reason, file: s.file })
|
|
233
|
+
return true
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const reaper = setInterval(() => {
|
|
237
|
+
const now = Date.now()
|
|
238
|
+
for (const [id, s] of sessions) {
|
|
239
|
+
if (now - s.lastHeartbeat > HEARTBEAT_TTL_MS) closeSession(id, 'ttl')
|
|
240
|
+
}
|
|
241
|
+
}, REAP_INTERVAL_MS)
|
|
242
|
+
reaper.unref && reaper.unref()
|
|
243
|
+
|
|
244
|
+
function onFileChange(session) {
|
|
245
|
+
if (session.timer) clearTimeout(session.timer)
|
|
246
|
+
session.timer = setTimeout(() => {
|
|
247
|
+
session.timer = null
|
|
248
|
+
let raw
|
|
249
|
+
try {
|
|
250
|
+
raw = fs.readFileSync(session.file, 'utf8')
|
|
251
|
+
} catch (_) {
|
|
252
|
+
return // 文件被外部删除:等 TTL / 前端断开收尾
|
|
253
|
+
}
|
|
254
|
+
const hash = hashContent(normalizeEol(raw))
|
|
255
|
+
if (hash === session.lastHash) return // 服务端自写回声 / 无实质变化
|
|
256
|
+
session.lastHash = hash
|
|
257
|
+
push({ id: session.id }, 'code-bridge-changed')
|
|
258
|
+
}, WATCH_DEBOUNCE_MS)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function openSession(code, label) {
|
|
262
|
+
const id = crypto.randomBytes(4).toString('hex')
|
|
263
|
+
const file = path.join(bridgeDir(), `${id}.js`)
|
|
264
|
+
const text = normalizeEol(code == null ? '' : code)
|
|
265
|
+
fs.mkdirSync(bridgeDir(), { recursive: true })
|
|
266
|
+
fs.writeFileSync(file, text, 'utf8')
|
|
267
|
+
const session = {
|
|
268
|
+
id,
|
|
269
|
+
file,
|
|
270
|
+
label: String(label || ''),
|
|
271
|
+
lastHash: hashContent(text),
|
|
272
|
+
lastHeartbeat: Date.now(),
|
|
273
|
+
watcher: null,
|
|
274
|
+
timer: null,
|
|
275
|
+
}
|
|
276
|
+
const watch = typeof watchFile === 'function' ? watchFile : defaultWatchFile
|
|
277
|
+
session.watcher = watch(file, () => onFileChange(session))
|
|
278
|
+
sessions.set(id, session)
|
|
279
|
+
const launched = launchEditor(file)
|
|
280
|
+
log.ok('代码桥会话建立', { id, file, label: session.label, launched })
|
|
281
|
+
return { id, file, launched }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const sendJson = (res, status, payload) => {
|
|
285
|
+
res.statusCode = status
|
|
286
|
+
res.setHeader('Content-Type', 'application/json')
|
|
287
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
288
|
+
res.end(JSON.stringify(payload))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const readBody = (req, limit = MAX_BODY) =>
|
|
292
|
+
new Promise((resolve, reject) => {
|
|
293
|
+
let body = ''
|
|
294
|
+
req.on('data', (c) => {
|
|
295
|
+
body += c
|
|
296
|
+
if (body.length > limit) {
|
|
297
|
+
reject(new Error('body too large'))
|
|
298
|
+
req.destroy()
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
req.on('end', () => resolve(body))
|
|
302
|
+
req.on('error', reject)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
return async function codeBridgeMiddleware(req, res, next) {
|
|
306
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
307
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
308
|
+
if (!pathname.startsWith('/__code-bridge')) return next()
|
|
309
|
+
|
|
310
|
+
// POST /__code-bridge —— 建立会话
|
|
311
|
+
if (pathname === '/__code-bridge' && method === 'POST') {
|
|
312
|
+
let payload
|
|
313
|
+
try {
|
|
314
|
+
payload = JSON.parse((await readBody(req)) || '{}')
|
|
315
|
+
} catch (_) {
|
|
316
|
+
return sendJson(res, 400, { ok: false, error: 'invalid json' })
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const opened = openSession(payload.code, payload.label)
|
|
320
|
+
return sendJson(res, 200, { ok: true, ...opened })
|
|
321
|
+
} catch (e) {
|
|
322
|
+
log.error('代码桥建立会话失败', e)
|
|
323
|
+
return sendJson(res, 500, { ok: false, error: String(e.message) })
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 其余端点:/__code-bridge/<id>[/heartbeat]
|
|
328
|
+
const m = pathname.match(/^\/__code-bridge\/([a-f0-9]{8})(\/heartbeat)?$/)
|
|
329
|
+
if (!m) return sendJson(res, 404, { ok: false, error: 'unknown bridge route' })
|
|
330
|
+
const [, id, isHeartbeat] = m
|
|
331
|
+
const session = sessions.get(id)
|
|
332
|
+
|
|
333
|
+
if (isHeartbeat && method === 'POST') {
|
|
334
|
+
if (!session) return sendJson(res, 404, { ok: false, error: 'session not found', id })
|
|
335
|
+
session.lastHeartbeat = Date.now()
|
|
336
|
+
return sendJson(res, 200, { ok: true, id })
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!isHeartbeat && method === 'GET') {
|
|
340
|
+
if (!session) return sendJson(res, 404, { ok: false, error: 'session not found', id })
|
|
341
|
+
let raw = ''
|
|
342
|
+
try {
|
|
343
|
+
raw = fs.readFileSync(session.file, 'utf8')
|
|
344
|
+
} catch (e) {
|
|
345
|
+
return sendJson(res, 410, { ok: false, error: 'temp file gone', id })
|
|
346
|
+
}
|
|
347
|
+
session.lastHeartbeat = Date.now()
|
|
348
|
+
const code = normalizeEol(raw)
|
|
349
|
+
return sendJson(res, 200, { ok: true, id, code, hash: hashContent(code) })
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!isHeartbeat && method === 'DELETE') {
|
|
353
|
+
const closed = closeSession(id, 'client')
|
|
354
|
+
return sendJson(res, closed ? 200 : 404, { ok: closed, id })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -27,6 +27,16 @@ import { createAssetProxyMiddleware } from './assetProxy.js'
|
|
|
27
27
|
import { createSliceStaticMiddleware } from './sliceStatic.js'
|
|
28
28
|
import { createCardImageMiddleware } from './cardImage.js'
|
|
29
29
|
import { createOpenFolderMiddleware } from './cardOpenFolder.js'
|
|
30
|
+
import { createCodeBridgeMiddleware } from './codeBridge.js'
|
|
31
|
+
import {
|
|
32
|
+
createLogMiddleware,
|
|
33
|
+
installLogMirror,
|
|
34
|
+
subscribeLog,
|
|
35
|
+
pushLogText,
|
|
36
|
+
getLogSnapshot,
|
|
37
|
+
clearLogBuffer,
|
|
38
|
+
logStreamEnabled,
|
|
39
|
+
} from './logStream.js'
|
|
30
40
|
import { buildDataFromSchema } from './buildDataFromSchema.js'
|
|
31
41
|
|
|
32
42
|
export {
|
|
@@ -47,16 +57,26 @@ export {
|
|
|
47
57
|
createSliceStaticMiddleware,
|
|
48
58
|
createCardImageMiddleware,
|
|
49
59
|
createOpenFolderMiddleware,
|
|
60
|
+
createCodeBridgeMiddleware,
|
|
61
|
+
createLogMiddleware,
|
|
62
|
+
installLogMirror,
|
|
63
|
+
subscribeLog,
|
|
64
|
+
pushLogText,
|
|
65
|
+
getLogSnapshot,
|
|
66
|
+
clearLogBuffer,
|
|
67
|
+
logStreamEnabled,
|
|
50
68
|
buildDataFromSchema,
|
|
51
69
|
}
|
|
52
70
|
|
|
53
71
|
/**
|
|
54
72
|
* 把全部卡片中间件挂到 Connect 风格容器(需实现 .use(mw))。
|
|
55
73
|
* @param {{ use: (mw: Function) => void }} app
|
|
56
|
-
* @param {{ root: string, viteServer?: import('vite').ViteDevServer | null, upstream?: string }} opts
|
|
74
|
+
* @param {{ root: string, viteServer?: import('vite').ViteDevServer | null, upstream?: string, notify?: (data: object, event?: string) => void, watchFile?: (file: string, cb: () => void) => { close: () => void } }} opts
|
|
57
75
|
* - upstream:FinMall 远端站点基址(来自 FINMALL_BASE_URL),资源代理回源用;空串表示不回源。
|
|
76
|
+
* - notify:SSE 推送入口(cardbuddy 的 /__events hub);代码桥经它推 bridge 事件,缺省时静默降级。
|
|
77
|
+
* - watchFile:代码桥单文件监听能力注入(chokidar 版);缺省代码桥用 fs.watch 兜底。
|
|
58
78
|
*/
|
|
59
|
-
export function mountCoreMiddlewares(app, { root, viteServer = null, upstream = '' }) {
|
|
79
|
+
export function mountCoreMiddlewares(app, { root, viteServer = null, upstream = '', notify = null, watchFile = null }) {
|
|
60
80
|
app.use(createSyncMiddleware({ root }))
|
|
61
81
|
app.use(createPreviewMiddleware({ root }))
|
|
62
82
|
app.use(createLoginCredentialsMiddleware({ root }))
|
|
@@ -76,6 +96,11 @@ export function mountCoreMiddlewares(app, { root, viteServer = null, upstream =
|
|
|
76
96
|
app.use(createBboxMiddleware({ root }))
|
|
77
97
|
// 在系统文件管理器中打开当前卡片本地目录(编辑器顶栏「打开目录」按钮触发,跨平台由后端完成)
|
|
78
98
|
app.use(createOpenFolderMiddleware({ root }))
|
|
99
|
+
// 外部编辑器代码桥(VSCode / DSL_EDITOR_CMD):临时 js + 保存回写 + 会话 TTL 清理
|
|
100
|
+
app.use(createCodeBridgeMiddleware({ root, notify, watchFile }))
|
|
101
|
+
// 服务日志流(编辑器底部「服务日志」面板):GET /__logs 取历史 / 断线补漏,
|
|
102
|
+
// POST /__logs/ingest 供外部进程(启动器)注入;实时推送由 SSE hub 侧订阅完成。
|
|
103
|
+
app.use(createLogMiddleware())
|
|
79
104
|
// 卡片预览图静态服务:/__card-image/<id> → src/cards/<id>/<id>.<ext>(编辑器网格预览缩略图用,
|
|
80
105
|
// 复用本地静态服务,避免实时无头截图)。
|
|
81
106
|
app.use(createCardImageMiddleware({ root }))
|