@capyclaw/editor-host 0.0.0-stage → 0.1.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/README.md +36 -2
- package/cordis.patch.yml +4 -0
- package/dsh-suite.json +29 -0
- package/lib/host.mjs +11 -0
- package/package.json +44 -4
- package/src/index.mjs +125 -0
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-editor-host
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DSH 公共编辑器宿主插件。功能插件只提供 `editor/` 静态目录,不再自己写静态路由、租约和连接状态。
|
|
4
|
+
|
|
5
|
+
## 使用
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
export const inject = ['editorHost', 'serviceHost']
|
|
9
|
+
|
|
10
|
+
export function apply(ctx, config) {
|
|
11
|
+
const editor = ctx.editorHost.createEditorHost({
|
|
12
|
+
id: 'my-editor',
|
|
13
|
+
dir: path.join(packageDir, 'editor'),
|
|
14
|
+
basePath: '/editor',
|
|
15
|
+
files: { '/': 'index.html', '/app.js': 'app.js' },
|
|
16
|
+
})
|
|
17
|
+
const app = ctx.serviceHost.createApp({
|
|
18
|
+
id: 'my-plugin',
|
|
19
|
+
health: () => editor.status(),
|
|
20
|
+
})
|
|
21
|
+
for (const route of editor.routes()) app.use(route)
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 提供什么
|
|
26
|
+
|
|
27
|
+
- `createEditorHost({ id, dir, basePath, index, files })`
|
|
28
|
+
- `routes()`:静态文件 + 租约端点
|
|
29
|
+
- `status()`:`{ connected, lease }`,可直接并入 `/healthz`
|
|
30
|
+
- `createLease()`:`dsh-plugin-kit/editor/lease` 的租约仲裁
|
|
31
|
+
- 端点:`POST <base>/claim`、`POST <base>/release`、`GET <base>/status`
|
|
32
|
+
|
|
33
|
+
## 约定
|
|
34
|
+
|
|
35
|
+
- “显示即连接”:页面加载后自动 claim;后打开者强制抢占
|
|
36
|
+
- 租约状态只用于内部仲裁,不渲染成“未连接/待命”
|
|
37
|
+
- 心跳间隔应小于 TTL(默认 3s / 15s)
|
package/cordis.patch.yml
ADDED
package/dsh-suite.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../dsh-suite-kit/suite.schema.json",
|
|
3
|
+
"name": "dsh-editor-host-suite",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"title": "Editor Host",
|
|
6
|
+
"description": "通用编辑器宿主:静态托管、租约、心跳、连接状态。",
|
|
7
|
+
"packages": {
|
|
8
|
+
"@capyclaw/editor-host": {
|
|
9
|
+
"role": "provider",
|
|
10
|
+
"version": "^0.1.1",
|
|
11
|
+
"path": ".",
|
|
12
|
+
"provides": {
|
|
13
|
+
"services": [
|
|
14
|
+
"editorHost"
|
|
15
|
+
],
|
|
16
|
+
"capabilities": {
|
|
17
|
+
"editor.host": "1.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"rows": [
|
|
23
|
+
{
|
|
24
|
+
"id": "editor-host",
|
|
25
|
+
"name": "@capyclaw/editor-host"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"checks": []
|
|
29
|
+
}
|
package/lib/host.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** dsh-editor-host 插件:向同进程插件提供 ctx.editorHost。 */
|
|
2
|
+
import { createEditorHostService } from '../src/index.mjs'
|
|
3
|
+
|
|
4
|
+
export const name = 'editor-host'
|
|
5
|
+
|
|
6
|
+
export function apply(ctx, config = {}) {
|
|
7
|
+
const service = createEditorHostService(config)
|
|
8
|
+
const dispose = ctx.provide('editorHost', service)
|
|
9
|
+
console.log('[editor-host] ready')
|
|
10
|
+
return () => { if (typeof dispose === 'function') dispose() }
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capyclaw/editor-host",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "DSH 公共编辑器宿主插件:静态托管、租约仲裁、心跳与连接状态,向插件提供 ctx.editorHost。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/host.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/host.mjs",
|
|
9
|
+
"./src": "./src/index.mjs",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18",
|
|
14
|
+
"dsh": "^0.2.0-rc.2"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"suite": "./dsh-suite.json",
|
|
24
|
+
"apiVersion": "1.0.0",
|
|
25
|
+
"category": "service",
|
|
26
|
+
"platform": "both"
|
|
27
|
+
},
|
|
28
|
+
"meta": {
|
|
29
|
+
"title": "公共编辑器宿主",
|
|
30
|
+
"description": "静态托管、租约仲裁、心跳与连接状态,向插件提供 ctx.editorHost"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"lib/",
|
|
34
|
+
"src/",
|
|
35
|
+
"cordis.patch.yml",
|
|
36
|
+
"dsh-suite.json",
|
|
37
|
+
"README.md"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node --test test/unit.test.mjs"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@capyclaw/plugin-kit": "^0.2.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,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 '@capyclaw/plugin-kit/editor/lease'
|
|
15
|
+
import { send, readBody } from '@capyclaw/plugin-kit/service/http'
|
|
16
|
+
import { createServiceApi } from '@capyclaw/plugin-kit/service/api'
|
|
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
|
+
}
|