@capyclaw/ppt-workbench 2.4.0 → 2.4.1

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/package.json CHANGED
@@ -1,53 +1,53 @@
1
- {
2
- "name": "@capyclaw/ppt-workbench",
3
- "version": "2.4.0",
4
- "description": "PPT 工作台运行时:27 个 ppt_* MCP 工具(导入/导出 PPTX + 图表/表格/公式/形状/线条/备注 + 契约探测)+ 本机实时 PPTist 编辑器。",
5
- "type": "module",
6
- "main": "./lib/host.mjs",
7
- "exports": {
8
- ".": "./lib/host.mjs",
9
- "./package.json": "./package.json"
10
- },
11
- "dsh": {
12
- "bundle": {
13
- "patch": "./cordis.patch.yml"
14
- },
15
- "capabilities": {
16
- "source": "server/tools/capabilities",
17
- "manifest": "capabilities.json"
18
- }
19
- },
20
- "meta": {
21
- "title": "PPT 工作台运行时",
22
- "description": "AI 生成/编辑 PPT:27 个 ppt_* 工具 + 浏览器实时编辑器(http://127.0.0.1:39311/)"
23
- },
24
- "files": [
25
- "lib/",
26
- "server/",
27
- "core/",
28
- "!server/.relay-instance.lock",
29
- "editor/",
30
- "contract/",
31
- "cordis.patch.yml",
32
- "README.md",
33
- "THIRD_PARTY_NOTICES.md"
34
- ],
35
- "license": "AGPL-3.0-or-later",
36
- "dependencies": {
37
- "dsh-document-workbench": "^0.1.0",
38
- "pptxgenjs": "^3.12.0"
39
- },
40
- "author": {
41
- "name": "CapyClaw",
42
- "url": "https://github.com/capyclaw",
43
- "email": "support@capyclaw.dev"
44
- },
45
- "repository": {
46
- "type": "git",
47
- "url": "https://github.com/capyclaw/dsh-plugins"
48
- },
49
- "bugs": {
50
- "url": "https://github.com/capyclaw/dsh-plugins/issues"
51
- },
52
- "homepage": "https://github.com/capyclaw/dsh-plugins"
53
- }
1
+ {
2
+ "name": "@capyclaw/ppt-workbench",
3
+ "version": "2.4.1",
4
+ "description": "PPT 工作台运行时:27 个 ppt_* MCP 工具(导入/导出 PPTX + 图表/表格/公式/形状/线条/备注 + 契约探测)+ 本机实时 PPTist 编辑器。",
5
+ "type": "module",
6
+ "main": "./lib/host.mjs",
7
+ "exports": {
8
+ ".": "./lib/host.mjs",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "dsh": {
12
+ "bundle": {
13
+ "patch": "./cordis.patch.yml"
14
+ },
15
+ "capabilities": {
16
+ "source": "server/tools/capabilities",
17
+ "manifest": "capabilities.json"
18
+ }
19
+ },
20
+ "meta": {
21
+ "title": "PPT 工作台运行时",
22
+ "description": "AI 生成/编辑 PPT:27 个 ppt_* 工具 + 浏览器实时编辑器(http://127.0.0.1:39311/)"
23
+ },
24
+ "files": [
25
+ "lib/",
26
+ "server/",
27
+ "core/",
28
+ "!server/.relay-instance.lock",
29
+ "editor/",
30
+ "contract/",
31
+ "cordis.patch.yml",
32
+ "README.md",
33
+ "THIRD_PARTY_NOTICES.md"
34
+ ],
35
+ "license": "AGPL-3.0-or-later",
36
+ "dependencies": {
37
+ "pptxgenjs": "^3.12.0",
38
+ "@capyclaw/document-workbench": "^0.1.0"
39
+ },
40
+ "author": {
41
+ "name": "CapyClaw",
42
+ "url": "https://github.com/capyclaw",
43
+ "email": "support@capyclaw.dev"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/capyclaw/dsh-plugins"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/capyclaw/dsh-plugins/issues"
51
+ },
52
+ "homepage": "https://github.com/capyclaw/dsh-plugins"
53
+ }
@@ -1,2 +1,2 @@
1
- /** 兼容入口:HTTP 传输实现统一来自 dsh-plugin-kit 的构建快照。 */
2
- export * from './infra/http.mjs'
1
+ /** 兼容入口:HTTP 传输实现统一来自 @capyclaw/plugin-kit 的构建快照。 */
2
+ export * from './infra/http.mjs'
@@ -1,82 +1,82 @@
1
- /**
2
- * dsh-plugin-kit 运行时调优层。
3
- *
4
- * 默认值与进化搜索(evolution/ NSGA-II)的 founder 基线保持一致;
5
- * 生产环境可用 DSH_TUNING_JSON 覆盖(按包名分组),测试/评估用 setTuning 注入。
6
- *
7
- * DSH_TUNING_JSON 形如:
8
- * {"dsh-plugin-kit": {"fileLockPollMs": 25, "journalMaxBytes": 2097152}}
9
- */
10
-
11
- const DEFAULTS = {
12
- /* fs-lock:元数据文件锁
13
- * pollMs=10 由 NSGA-II 进化搜索固化(evolution/,2026-11-01):
14
- * 24 路竞争延迟 2202→1400ms;10ms 为延迟与轮询 IO 的膝点。 */
15
- fileLockSyncTimeoutMs: 10000,
16
- fileLockSyncStaleMs: 30000,
17
- fileLockAsyncTimeoutMs: 30000,
18
- fileLockPollMs: 10,
19
- fileLockFreshGraceMs: 2000,
20
-
21
- /* 编辑器租约:wait() 轮询节奏 */
22
- leaseWaitPollMs: 200,
23
- leaseWaitTimeoutMs: 4000,
24
-
25
- /* JournalStore:journal 轮转阈值与尾读默认行数
26
- * 512KB 由进化搜索固化:尾读 p95 4.3→1.9ms、磁盘 -70%、追加吞吐 +8%。 */
27
- journalMaxBytes: 512 * 1024,
28
- journalTailLimit: 8,
29
-
30
- /* WorkspaceManager catalog 写节流(降低全局 catalog 锁竞争) */
31
- catalogThrottleMs: 5000,
32
-
33
- /* WorkspaceClient token 校验缓存 */
34
- tokenVerifyCacheMs: 30000,
35
-
36
- /* HTTP 服务三级超时(keepAliveTimeoutMs 必须小于 headersTimeoutMs) */
37
- requestTimeoutMs: 30000,
38
- headersTimeoutMs: 10000,
39
- keepAliveTimeoutMs: 5000,
40
- }
41
-
42
- let overrides = {}
43
- let envLoaded = false
44
-
45
- function loadEnv() {
46
- if (envLoaded) return
47
- envLoaded = true
48
- try {
49
- const raw = process.env.DSH_TUNING_JSON
50
- if (!raw) return
51
- const parsed = JSON.parse(raw)
52
- if (!parsed || typeof parsed !== 'object') return
53
- const own = parsed['dsh-plugin-kit'] && typeof parsed['dsh-plugin-kit'] === 'object'
54
- ? parsed['dsh-plugin-kit']
55
- : parsed
56
- for (const key of Object.keys(DEFAULTS)) {
57
- if (own[key] !== undefined) overrides[key] = own[key]
58
- }
59
- } catch { /* 非法 JSON 静默忽略:调优失败不能阻塞启动 */ }
60
- }
61
-
62
- /** 当前生效参数(默认值 + env + setTuning 覆盖)。 */
63
- export function tuning() {
64
- loadEnv()
65
- return { ...DEFAULTS, ...overrides }
66
- }
67
-
68
- /** 测试/评估注入。只接受已知键,避免拼写错误静默生效。 */
69
- export function setTuning(patch = {}) {
70
- loadEnv()
71
- for (const [key, value] of Object.entries(patch)) {
72
- if (key in DEFAULTS) overrides[key] = value
73
- }
74
- }
75
-
76
- /** 恢复到默认值 + env 覆盖。 */
77
- export function resetTuning() {
78
- overrides = {}
79
- envLoaded = false
80
- }
81
-
82
- export const TUNING_DEFAULTS = { ...DEFAULTS }
1
+ /**
2
+ * @capyclaw/plugin-kit 运行时调优层。
3
+ *
4
+ * 默认值与进化搜索(evolution/ NSGA-II)的 founder 基线保持一致;
5
+ * 生产环境可用 DSH_TUNING_JSON 覆盖(按包名分组),测试/评估用 setTuning 注入。
6
+ *
7
+ * DSH_TUNING_JSON 形如:
8
+ * {"@capyclaw/plugin-kit": {"fileLockPollMs": 25, "journalMaxBytes": 2097152}}
9
+ */
10
+
11
+ const DEFAULTS = {
12
+ /* fs-lock:元数据文件锁
13
+ * pollMs=10 由 NSGA-II 进化搜索固化(evolution/,2026-11-01):
14
+ * 24 路竞争延迟 2202→1400ms;10ms 为延迟与轮询 IO 的膝点。 */
15
+ fileLockSyncTimeoutMs: 10000,
16
+ fileLockSyncStaleMs: 30000,
17
+ fileLockAsyncTimeoutMs: 30000,
18
+ fileLockPollMs: 10,
19
+ fileLockFreshGraceMs: 2000,
20
+
21
+ /* 编辑器租约:wait() 轮询节奏 */
22
+ leaseWaitPollMs: 200,
23
+ leaseWaitTimeoutMs: 4000,
24
+
25
+ /* JournalStore:journal 轮转阈值与尾读默认行数
26
+ * 512KB 由进化搜索固化:尾读 p95 4.3→1.9ms、磁盘 -70%、追加吞吐 +8%。 */
27
+ journalMaxBytes: 512 * 1024,
28
+ journalTailLimit: 8,
29
+
30
+ /* WorkspaceManager catalog 写节流(降低全局 catalog 锁竞争) */
31
+ catalogThrottleMs: 5000,
32
+
33
+ /* WorkspaceClient token 校验缓存 */
34
+ tokenVerifyCacheMs: 30000,
35
+
36
+ /* HTTP 服务三级超时(keepAliveTimeoutMs 必须小于 headersTimeoutMs) */
37
+ requestTimeoutMs: 30000,
38
+ headersTimeoutMs: 10000,
39
+ keepAliveTimeoutMs: 5000,
40
+ }
41
+
42
+ let overrides = {}
43
+ let envLoaded = false
44
+
45
+ function loadEnv() {
46
+ if (envLoaded) return
47
+ envLoaded = true
48
+ try {
49
+ const raw = process.env.DSH_TUNING_JSON
50
+ if (!raw) return
51
+ const parsed = JSON.parse(raw)
52
+ if (!parsed || typeof parsed !== 'object') return
53
+ const own = parsed['@capyclaw/plugin-kit'] && typeof parsed['@capyclaw/plugin-kit'] === 'object'
54
+ ? parsed['@capyclaw/plugin-kit']
55
+ : parsed
56
+ for (const key of Object.keys(DEFAULTS)) {
57
+ if (own[key] !== undefined) overrides[key] = own[key]
58
+ }
59
+ } catch { /* 非法 JSON 静默忽略:调优失败不能阻塞启动 */ }
60
+ }
61
+
62
+ /** 当前生效参数(默认值 + env + setTuning 覆盖)。 */
63
+ export function tuning() {
64
+ loadEnv()
65
+ return { ...DEFAULTS, ...overrides }
66
+ }
67
+
68
+ /** 测试/评估注入。只接受已知键,避免拼写错误静默生效。 */
69
+ export function setTuning(patch = {}) {
70
+ loadEnv()
71
+ for (const [key, value] of Object.entries(patch)) {
72
+ if (key in DEFAULTS) overrides[key] = value
73
+ }
74
+ }
75
+
76
+ /** 恢复到默认值 + env 覆盖。 */
77
+ export function resetTuning() {
78
+ overrides = {}
79
+ envLoaded = false
80
+ }
81
+
82
+ export const TUNING_DEFAULTS = { ...DEFAULTS }
@@ -1,47 +1,47 @@
1
- /**
2
- * PPT 桥端点:协议与路由由 dsh-document-workbench 提供,本文件只注入
3
- * PPT 的 deck 投影、VIEW_OPS 与 applyOp。
4
- */
5
- import { createDocumentBridge } from 'dsh-document-workbench/bridge'
6
- import { BASE_PATH, VIEW_OPS } from '../constants.mjs'
7
- import { getTuning } from '../tuning.mjs'
8
- import {
9
- sanitizeAck, sanitizeClaim, sanitizePush, sanitizeShotResult, sanitizeState,
10
- } from '../store.mjs'
11
-
12
- export function createBridgeRoutes({ store, jsonP, applyOp }) {
13
- const tuning = getTuning()
14
- return createDocumentBridge({
15
- session: store,
16
- basePath: BASE_PATH,
17
- bridgeName: 'ppt-bridge',
18
- documentKey: 'deck',
19
- documentPath: '/deck',
20
- authority: 'server',
21
- apply: (cmd) => applyOp(jsonP, cmd),
22
- isViewOp: (cmd) => VIEW_OPS.has(cmd.op),
23
- sanitizers: {
24
- sanitizeAck,
25
- sanitizeClaim,
26
- sanitizePush,
27
- sanitizeShotResult,
28
- sanitizeState,
29
- },
30
- bodyLimitBytes: Number(tuning.bodyLimitBytes) || 10 * 1024 * 1024,
31
- watch: {
32
- pollMs: tuning.watchPollMs,
33
- timeoutSec: tuning.watchTimeoutSec,
34
- maxTimeoutSec: tuning.watchTimeoutMaxSec,
35
- },
36
- projectStatus: (doc) => ({
37
- bridge: 'ppt-workbench',
38
- has_deck: !!(doc.deck && Array.isArray(doc.deck.slides) && doc.deck.slides.length),
39
- slides: doc.deck && Array.isArray(doc.deck.slides) ? doc.deck.slides.length : 0,
40
- }),
41
- projectDocument: (doc) => ({
42
- deck: doc.deck || {},
43
- state_seq: doc.state_seq | 0,
44
- applied_seq: doc.applied_seq | 0,
45
- }),
46
- }).routes
47
- }
1
+ /**
2
+ * PPT 桥端点:协议与路由由 @capyclaw/document-workbench 提供,本文件只注入
3
+ * PPT 的 deck 投影、VIEW_OPS 与 applyOp。
4
+ */
5
+ import { createDocumentBridge } from '@capyclaw/document-workbench/bridge'
6
+ import { BASE_PATH, VIEW_OPS } from '../constants.mjs'
7
+ import { getTuning } from '../tuning.mjs'
8
+ import {
9
+ sanitizeAck, sanitizeClaim, sanitizePush, sanitizeShotResult, sanitizeState,
10
+ } from '../store.mjs'
11
+
12
+ export function createBridgeRoutes({ store, jsonP, applyOp }) {
13
+ const tuning = getTuning()
14
+ return createDocumentBridge({
15
+ session: store,
16
+ basePath: BASE_PATH,
17
+ bridgeName: 'ppt-bridge',
18
+ documentKey: 'deck',
19
+ documentPath: '/deck',
20
+ authority: 'server',
21
+ apply: (cmd) => applyOp(jsonP, cmd),
22
+ isViewOp: (cmd) => VIEW_OPS.has(cmd.op),
23
+ sanitizers: {
24
+ sanitizeAck,
25
+ sanitizeClaim,
26
+ sanitizePush,
27
+ sanitizeShotResult,
28
+ sanitizeState,
29
+ },
30
+ bodyLimitBytes: Number(tuning.bodyLimitBytes) || 10 * 1024 * 1024,
31
+ watch: {
32
+ pollMs: tuning.watchPollMs,
33
+ timeoutSec: tuning.watchTimeoutSec,
34
+ maxTimeoutSec: tuning.watchTimeoutMaxSec,
35
+ },
36
+ projectStatus: (doc) => ({
37
+ bridge: 'ppt-workbench',
38
+ has_deck: !!(doc.deck && Array.isArray(doc.deck.slides) && doc.deck.slides.length),
39
+ slides: doc.deck && Array.isArray(doc.deck.slides) ? doc.deck.slides.length : 0,
40
+ }),
41
+ projectDocument: (doc) => ({
42
+ deck: doc.deck || {},
43
+ state_seq: doc.state_seq | 0,
44
+ applied_seq: doc.applied_seq | 0,
45
+ }),
46
+ }).routes
47
+ }
@@ -1,17 +1,17 @@
1
- /** 兼容层:长轮询的通用实现位于 dsh-document-workbench/session。 */
2
- import { watchSession } from 'dsh-document-workbench/session'
3
- import { getTuning } from '../tuning.mjs'
4
-
5
- export function watchDoc(store, seq0, shot0, ui0, timeoutS, aborted) {
6
- const tuning = getTuning()
7
- return watchSession(store, {
8
- seq: seq0,
9
- shot: shot0,
10
- ui: ui0,
11
- timeoutS,
12
- isAborted: aborted,
13
- pollMs: tuning.watchPollMs,
14
- timeoutSec: tuning.watchTimeoutSec,
15
- maxTimeoutSec: tuning.watchTimeoutMaxSec,
16
- })
17
- }
1
+ /** 兼容层:长轮询的通用实现位于 @capyclaw/document-workbench/session。 */
2
+ import { watchSession } from '@capyclaw/document-workbench/session'
3
+ import { getTuning } from '../tuning.mjs'
4
+
5
+ export function watchDoc(store, seq0, shot0, ui0, timeoutS, aborted) {
6
+ const tuning = getTuning()
7
+ return watchSession(store, {
8
+ seq: seq0,
9
+ shot: shot0,
10
+ ui: ui0,
11
+ timeoutS,
12
+ isAborted: aborted,
13
+ pollMs: tuning.watchPollMs,
14
+ timeoutSec: tuning.watchTimeoutSec,
15
+ maxTimeoutSec: tuning.watchTimeoutMaxSec,
16
+ })
17
+ }
@@ -1,125 +1,125 @@
1
- /**
2
- * dsh-editor-host —— 通用浏览器编辑器宿主。
3
- *
4
- * 功能插件只提供 editor/ 静态目录,不再自己写:
5
- * - 静态文件路由 / content-type / 404
6
- * - “显示即连接”的租约仲裁(后打开者抢占,旧窗口静默待命)
7
- * - claim / release / status / heartbeat 端点
8
- * - 连接状态供 /healthz 使用
9
- *
10
- * 浏览器端可直接使用 dsh-plugin-kit 的 editor/lease.mjs(无 Node 依赖)。
11
- */
12
- import fs from 'node:fs'
13
- import path from 'node:path'
14
- import { createEditorLease } from './kit/editor/lease.mjs'
15
- import { send, readBody } from './kit/service/http.mjs'
16
- import { createServiceApi } from './kit/service/api.mjs'
17
-
18
- const MIME = {
19
- '.html': 'text/html; charset=utf-8',
20
- '.js': 'text/javascript; charset=utf-8',
21
- '.mjs': 'text/javascript; charset=utf-8',
22
- '.css': 'text/css; charset=utf-8',
23
- '.json': 'application/json; charset=utf-8',
24
- '.svg': 'image/svg+xml',
25
- '.png': 'image/png',
26
- '.jpg': 'image/jpeg',
27
- '.jpeg': 'image/jpeg',
28
- '.webp': 'image/webp',
29
- '.woff2': 'font/woff2',
30
- }
31
-
32
- function safeJoin(root, rel) {
33
- const target = path.resolve(root, `.${path.sep}${rel}`)
34
- const base = path.resolve(root)
35
- return target === base || target.startsWith(base + path.sep) ? target : null
36
- }
37
-
38
- function contentType(file) {
39
- return MIME[path.extname(file).toLowerCase()] || 'application/octet-stream'
40
- }
41
-
42
- /**
43
- * @param {{ id: string, dir: string, basePath?: string, index?: string, files?: Record<string,string|string[]> }} options
44
- */
45
- export function createEditorHost({ id, dir, basePath = '', index = 'index.html', files = null } = {}) {
46
- if (!id) throw new Error('createEditorHost: id 必填')
47
- const prefix = basePath === '/' ? '' : String(basePath || '').replace(/\/+$/, '')
48
- const lease = createEditorLease()
49
-
50
- const staticRoutes = async function editorStatic(req, res, u) {
51
- if (req.method !== 'GET' && req.method !== 'HEAD') return false
52
- if (prefix && u.pathname !== prefix && !u.pathname.startsWith(prefix + '/')) return false
53
- const relRaw = prefix ? u.pathname.slice(prefix.length) : u.pathname
54
- const rel = relRaw === '' || relRaw === '/' ? index : decodeURIComponent(relRaw.replace(/^\/+/, ''))
55
- let file = null
56
- if (files) {
57
- const hit = files[relRaw === '' ? '/' : relRaw] || files[relRaw]
58
- if (!hit) return false
59
- const name = Array.isArray(hit) ? hit[0] : hit
60
- file = safeJoin(dir, name)
61
- } else {
62
- file = safeJoin(dir, rel)
63
- }
64
- if (!file) return false
65
- try {
66
- const data = fs.readFileSync(file)
67
- res.writeHead(200, {
68
- 'content-type': contentType(file),
69
- 'content-length': data.length,
70
- 'x-content-type-options': 'nosniff',
71
- 'referrer-policy': 'no-referrer',
72
- 'cache-control': 'no-cache',
73
- })
74
- res.end(req.method === 'HEAD' ? undefined : data)
75
- return true
76
- } catch {
77
- return false
78
- }
79
- }
80
-
81
- const leaseRoutes = async function editorLeaseRoutes(req, res, u) {
82
- const claimPath = `${prefix}/claim`
83
- const releasePath = `${prefix}/release`
84
- const statusPath = `${prefix}/status`
85
- if (u.pathname === claimPath && req.method === 'POST') {
86
- const body = await readBody(req)
87
- const result = lease.claim(String(body.id || ''), !!body.force)
88
- send(req, res, result.ok ? 200 : 409, result)
89
- return true
90
- }
91
- if (u.pathname === releasePath && req.method === 'POST') {
92
- const body = await readBody(req)
93
- send(req, res, 200, lease.release(String(body.id || '')))
94
- return true
95
- }
96
- if (u.pathname === statusPath && req.method === 'GET') {
97
- send(req, res, 200, { ok: true, connected: lease.isLive(), lease: lease.status() })
98
- return true
99
- }
100
- return false
101
- }
102
-
103
- return {
104
- id,
105
- lease,
106
- routes() {
107
- return [staticRoutes, leaseRoutes]
108
- },
109
- staticRoutes,
110
- leaseRoutes,
111
- status() {
112
- return { connected: lease.isLive(), lease: lease.status() }
113
- },
114
- }
115
- }
116
-
117
- export function createEditorHostService(config = {}) {
118
- return {
119
- api: createServiceApi('editorHost', '1.0.0', ['static', 'lease', 'heartbeat', 'status']),
120
- version: '0.1.0',
121
- createEditorHost,
122
- createLease: createEditorLease,
123
- defaultHost: config.dir ? createEditorHost({ id: config.id || 'editor', ...config }) : null,
124
- }
125
- }
1
+ /**
2
+ * dsh-editor-host —— 通用浏览器编辑器宿主。
3
+ *
4
+ * 功能插件只提供 editor/ 静态目录,不再自己写:
5
+ * - 静态文件路由 / content-type / 404
6
+ * - “显示即连接”的租约仲裁(后打开者抢占,旧窗口静默待命)
7
+ * - claim / release / status / heartbeat 端点
8
+ * - 连接状态供 /healthz 使用
9
+ *
10
+ * 浏览器端可直接使用 @capyclaw/plugin-kit 的 editor/lease.mjs(无 Node 依赖)。
11
+ */
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { createEditorLease } from './kit/editor/lease.mjs'
15
+ import { send, readBody } from './kit/service/http.mjs'
16
+ import { createServiceApi } from './kit/service/api.mjs'
17
+
18
+ const MIME = {
19
+ '.html': 'text/html; charset=utf-8',
20
+ '.js': 'text/javascript; charset=utf-8',
21
+ '.mjs': 'text/javascript; charset=utf-8',
22
+ '.css': 'text/css; charset=utf-8',
23
+ '.json': 'application/json; charset=utf-8',
24
+ '.svg': 'image/svg+xml',
25
+ '.png': 'image/png',
26
+ '.jpg': 'image/jpeg',
27
+ '.jpeg': 'image/jpeg',
28
+ '.webp': 'image/webp',
29
+ '.woff2': 'font/woff2',
30
+ }
31
+
32
+ function safeJoin(root, rel) {
33
+ const target = path.resolve(root, `.${path.sep}${rel}`)
34
+ const base = path.resolve(root)
35
+ return target === base || target.startsWith(base + path.sep) ? target : null
36
+ }
37
+
38
+ function contentType(file) {
39
+ return MIME[path.extname(file).toLowerCase()] || 'application/octet-stream'
40
+ }
41
+
42
+ /**
43
+ * @param {{ id: string, dir: string, basePath?: string, index?: string, files?: Record<string,string|string[]> }} options
44
+ */
45
+ export function createEditorHost({ id, dir, basePath = '', index = 'index.html', files = null } = {}) {
46
+ if (!id) throw new Error('createEditorHost: id 必填')
47
+ const prefix = basePath === '/' ? '' : String(basePath || '').replace(/\/+$/, '')
48
+ const lease = createEditorLease()
49
+
50
+ const staticRoutes = async function editorStatic(req, res, u) {
51
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false
52
+ if (prefix && u.pathname !== prefix && !u.pathname.startsWith(prefix + '/')) return false
53
+ const relRaw = prefix ? u.pathname.slice(prefix.length) : u.pathname
54
+ const rel = relRaw === '' || relRaw === '/' ? index : decodeURIComponent(relRaw.replace(/^\/+/, ''))
55
+ let file = null
56
+ if (files) {
57
+ const hit = files[relRaw === '' ? '/' : relRaw] || files[relRaw]
58
+ if (!hit) return false
59
+ const name = Array.isArray(hit) ? hit[0] : hit
60
+ file = safeJoin(dir, name)
61
+ } else {
62
+ file = safeJoin(dir, rel)
63
+ }
64
+ if (!file) return false
65
+ try {
66
+ const data = fs.readFileSync(file)
67
+ res.writeHead(200, {
68
+ 'content-type': contentType(file),
69
+ 'content-length': data.length,
70
+ 'x-content-type-options': 'nosniff',
71
+ 'referrer-policy': 'no-referrer',
72
+ 'cache-control': 'no-cache',
73
+ })
74
+ res.end(req.method === 'HEAD' ? undefined : data)
75
+ return true
76
+ } catch {
77
+ return false
78
+ }
79
+ }
80
+
81
+ const leaseRoutes = async function editorLeaseRoutes(req, res, u) {
82
+ const claimPath = `${prefix}/claim`
83
+ const releasePath = `${prefix}/release`
84
+ const statusPath = `${prefix}/status`
85
+ if (u.pathname === claimPath && req.method === 'POST') {
86
+ const body = await readBody(req)
87
+ const result = lease.claim(String(body.id || ''), !!body.force)
88
+ send(req, res, result.ok ? 200 : 409, result)
89
+ return true
90
+ }
91
+ if (u.pathname === releasePath && req.method === 'POST') {
92
+ const body = await readBody(req)
93
+ send(req, res, 200, lease.release(String(body.id || '')))
94
+ return true
95
+ }
96
+ if (u.pathname === statusPath && req.method === 'GET') {
97
+ send(req, res, 200, { ok: true, connected: lease.isLive(), lease: lease.status() })
98
+ return true
99
+ }
100
+ return false
101
+ }
102
+
103
+ return {
104
+ id,
105
+ lease,
106
+ routes() {
107
+ return [staticRoutes, leaseRoutes]
108
+ },
109
+ staticRoutes,
110
+ leaseRoutes,
111
+ status() {
112
+ return { connected: lease.isLive(), lease: lease.status() }
113
+ },
114
+ }
115
+ }
116
+
117
+ export function createEditorHostService(config = {}) {
118
+ return {
119
+ api: createServiceApi('editorHost', '1.0.0', ['static', 'lease', 'heartbeat', 'status']),
120
+ version: '0.1.0',
121
+ createEditorHost,
122
+ createLease: createEditorLease,
123
+ defaultHost: config.dir ? createEditorHost({ id: config.id || 'editor', ...config }) : null,
124
+ }
125
+ }