remnote-bridge 0.1.11 → 0.1.13
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/dist/cli/addon/addon-manager.js +163 -0
- package/dist/cli/addon/registry.js +24 -0
- package/dist/cli/commands/addon.js +149 -0
- package/dist/cli/commands/clean.js +121 -52
- package/dist/cli/commands/connect.js +72 -33
- package/dist/cli/commands/disconnect.js +19 -19
- package/dist/cli/commands/edit-rem.js +8 -36
- package/dist/cli/commands/edit-tree.js +3 -20
- package/dist/cli/commands/health.js +19 -18
- package/dist/cli/commands/read-context.js +3 -20
- package/dist/cli/commands/read-globe.js +3 -20
- package/dist/cli/commands/read-rem.js +6 -32
- package/dist/cli/commands/read-tree.js +3 -20
- package/dist/cli/commands/search.js +97 -21
- package/dist/cli/config.js +148 -72
- package/dist/cli/daemon/daemon.js +104 -24
- package/dist/cli/daemon/dev-server.js +9 -1
- package/dist/cli/daemon/pid.js +36 -22
- package/dist/cli/daemon/registry.js +160 -0
- package/dist/cli/daemon/send-request.js +11 -11
- package/dist/cli/daemon/static-server.js +97 -34
- package/dist/cli/handlers/edit-handler.js +49 -140
- package/dist/cli/handlers/read-handler.js +9 -9
- package/dist/cli/handlers/rem-cache.js +10 -5
- package/dist/cli/handlers/tree-parser.js +16 -9
- package/dist/cli/main.js +67 -19
- package/dist/cli/protocol.js +18 -4
- package/dist/cli/server/config-server.js +280 -14
- package/dist/cli/server/ws-server.js +93 -44
- package/dist/cli/utils/output.js +29 -0
- package/dist/mcp/format.js +43 -0
- package/dist/mcp/index.js +0 -55
- package/dist/mcp/instructions.js +424 -216
- package/dist/mcp/resources/edit-rem-guide.js +37 -158
- package/dist/mcp/resources/edit-tree-guide.js +1 -1
- package/dist/mcp/resources/error-reference.js +9 -13
- package/dist/mcp/resources/rem-object-fields.js +6 -6
- package/dist/mcp/tools/edit-tools.js +69 -8
- package/dist/mcp/tools/infra-tools.js +44 -8
- package/dist/mcp/tools/read-tools.js +136 -20
- package/package.json +2 -2
- package/remnote-plugin/dist/bridge_widget-sandbox.js +17 -17
- package/remnote-plugin/dist/bridge_widget.js +17 -17
- package/remnote-plugin/dist/index-sandbox.js +31 -31
- package/remnote-plugin/dist/index.js +31 -31
- package/remnote-plugin/dist/manifest.json +1 -1
- package/remnote-plugin/package.json +1 -1
- package/remnote-plugin/public/manifest.json +1 -1
- package/remnote-plugin/src/bridge/multi-connection-manager.ts +151 -0
- package/remnote-plugin/src/bridge/websocket-client.ts +62 -16
- package/remnote-plugin/src/services/index.ts +0 -8
- package/remnote-plugin/src/services/read-rem.ts +1 -9
- package/remnote-plugin/src/services/search.ts +13 -10
- package/remnote-plugin/src/settings.ts +9 -7
- package/remnote-plugin/src/utils/index.ts +0 -5
- package/remnote-plugin/src/widgets/bridge_widget.tsx +105 -20
- package/remnote-plugin/src/widgets/index.tsx +41 -44
- package/remnote-plugin/webpack.config.js +35 -0
- package/skills/remnote-bridge/SKILL.md +45 -40
- package/skills/remnote-bridge/instructions/addon.md +134 -0
- package/skills/remnote-bridge/instructions/clean.md +110 -0
- package/skills/remnote-bridge/instructions/connect.md +80 -37
- package/skills/remnote-bridge/instructions/disconnect.md +22 -9
- package/skills/remnote-bridge/instructions/edit-rem.md +113 -327
- package/skills/remnote-bridge/instructions/health.md +23 -13
- package/skills/remnote-bridge/instructions/install-skill.md +58 -0
- package/skills/remnote-bridge/instructions/overall.md +99 -35
- package/skills/remnote-bridge/instructions/read-rem.md +15 -15
- package/skills/remnote-bridge/instructions/search.md +77 -18
- package/skills/remnote-bridge/instructions/setup.md +5 -6
|
@@ -61,7 +61,15 @@ export class DevServerManager {
|
|
|
61
61
|
// shell: true 确保 Windows 上能找到 npm.cmd
|
|
62
62
|
this.child = spawn('npm', ['run', 'dev'], {
|
|
63
63
|
cwd: pluginDir,
|
|
64
|
-
env: {
|
|
64
|
+
env: {
|
|
65
|
+
...process.env,
|
|
66
|
+
PORT: String(port),
|
|
67
|
+
// 供 webpack devServer.setupMiddlewares 劫持 /api/discovery
|
|
68
|
+
DISCOVERY_WS_PORT: process.env.SLOT_WS_PORT ?? '',
|
|
69
|
+
DISCOVERY_CONFIG_PORT: process.env.SLOT_CONFIG_PORT ?? '',
|
|
70
|
+
DISCOVERY_INSTANCE: process.env.REMNOTE_BRIDGE_INSTANCE ?? '',
|
|
71
|
+
DISCOVERY_SLOT_INDEX: process.env.SLOT_INDEX ?? '',
|
|
72
|
+
},
|
|
65
73
|
stdio: 'pipe',
|
|
66
74
|
shell: true,
|
|
67
75
|
});
|
package/dist/cli/daemon/pid.js
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PID 文件管理
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* PID 文件为 JSON 格式,包含 pid、slotIndex、instance 和端口信息。
|
|
5
|
+
* 路径:~/.remnote-bridge/instances/N.pid
|
|
5
6
|
*/
|
|
6
7
|
import fs from 'fs';
|
|
8
|
+
import { execFileSync } from 'child_process';
|
|
7
9
|
/**
|
|
8
|
-
* 写入 PID
|
|
10
|
+
* 写入 PID 文件(JSON 格式)
|
|
9
11
|
*/
|
|
10
|
-
export function writePid(filePath,
|
|
11
|
-
fs.writeFileSync(filePath,
|
|
12
|
+
export function writePid(filePath, info) {
|
|
13
|
+
fs.writeFileSync(filePath, JSON.stringify(info, null, 2) + '\n', 'utf-8');
|
|
12
14
|
}
|
|
13
15
|
/**
|
|
14
|
-
* 读取 PID
|
|
16
|
+
* 读取 PID 文件。文件不存在或格式错误返回 null。
|
|
15
17
|
*/
|
|
16
|
-
export function
|
|
18
|
+
export function readPidInfo(filePath) {
|
|
17
19
|
try {
|
|
18
|
-
const content = fs.readFileSync(filePath, 'utf-8')
|
|
19
|
-
const
|
|
20
|
-
|
|
20
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
21
|
+
const parsed = JSON.parse(content);
|
|
22
|
+
if (typeof parsed.pid === 'number' && typeof parsed.slotIndex === 'number') {
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
21
26
|
}
|
|
22
27
|
catch {
|
|
23
28
|
return null;
|
|
@@ -47,21 +52,30 @@ export function isProcessAlive(pid) {
|
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
54
|
/**
|
|
50
|
-
*
|
|
51
|
-
* - { running: true, pid } — 守护进程正在运行
|
|
52
|
-
* - { running: false } — 守护进程未运行(无 PID 文件或 stale)
|
|
55
|
+
* 检查进程是否是我们的 daemon。
|
|
53
56
|
*
|
|
54
|
-
*
|
|
57
|
+
* 仅 kill -0 无法防止 PID recycling:OS 可能把同一 PID 分配给无关进程,
|
|
58
|
+
* 导致 cleanStaleSlots 误判槽位为"存活"而不清理。
|
|
59
|
+
* 此函数额外校验进程命令行是否包含 daemon 关键字。
|
|
55
60
|
*/
|
|
56
|
-
export function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
export function isDaemonAlive(pid) {
|
|
62
|
+
// PID 0 是 allocateSlot 的占位值,不是有效的 daemon PID
|
|
63
|
+
// process.kill(0, 0) 发信号给进程组,会误返回 true
|
|
64
|
+
if (pid <= 0)
|
|
65
|
+
return false;
|
|
66
|
+
if (!isProcessAlive(pid))
|
|
67
|
+
return false;
|
|
68
|
+
try {
|
|
69
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
70
|
+
encoding: 'utf-8',
|
|
71
|
+
timeout: 3000,
|
|
72
|
+
}).trim();
|
|
73
|
+
// daemon.ts 编译后路径包含 "daemon/daemon"(dist/cli/daemon/daemon.js)
|
|
74
|
+
// 使用更精确的匹配避免误匹配 dockerd 等含 "daemon" 的无关进程
|
|
75
|
+
return cmd.includes('daemon/daemon') || cmd.includes('daemon.js');
|
|
60
76
|
}
|
|
61
|
-
|
|
62
|
-
|
|
77
|
+
catch {
|
|
78
|
+
// ps 失败时回退到基本检查(kill -0 已通过)
|
|
79
|
+
return true;
|
|
63
80
|
}
|
|
64
|
-
// stale PID 文件,清理
|
|
65
|
-
removePid(pidPath);
|
|
66
|
-
return { running: false };
|
|
67
81
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 多实例注册表
|
|
3
|
+
*
|
|
4
|
+
* 管理最多 4 个 daemon 实例的端口槽位分配。
|
|
5
|
+
* 所有运行时文件存放在 ~/.remnote-bridge/:
|
|
6
|
+
* config.json — 全局配置
|
|
7
|
+
* slots.json — 4 组端口定义
|
|
8
|
+
* registry.json — instance → slot 映射
|
|
9
|
+
* instances/N.pid — 槽位 PID 文件(JSON)
|
|
10
|
+
* instances/N.log — 槽位日志
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { isDaemonAlive } from './pid.js';
|
|
15
|
+
import { GLOBAL_DIR, ensureGlobalDir } from '../config.js';
|
|
16
|
+
// ── 常量 ──
|
|
17
|
+
export const MAX_SLOTS = 4;
|
|
18
|
+
const SLOTS_FILE = path.join(GLOBAL_DIR, 'slots.json');
|
|
19
|
+
const REGISTRY_FILE = path.join(GLOBAL_DIR, 'registry.json');
|
|
20
|
+
const INSTANCES_DIR = path.join(GLOBAL_DIR, 'instances');
|
|
21
|
+
/** 默认 4 组端口槽位(29100 段,高位端口避免冲突) */
|
|
22
|
+
export const DEFAULT_SLOTS = [
|
|
23
|
+
{ wsPort: 29100, devServerPort: 29101, configPort: 29102 },
|
|
24
|
+
{ wsPort: 29110, devServerPort: 29111, configPort: 29112 },
|
|
25
|
+
{ wsPort: 29120, devServerPort: 29121, configPort: 29122 },
|
|
26
|
+
{ wsPort: 29130, devServerPort: 29131, configPort: 29132 },
|
|
27
|
+
];
|
|
28
|
+
// ── slots.json ──
|
|
29
|
+
export function loadSlots() {
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(SLOTS_FILE, 'utf-8');
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
if (Array.isArray(parsed) && parsed.length === MAX_SLOTS) {
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// 不存在或损坏
|
|
39
|
+
}
|
|
40
|
+
// 自动生成默认
|
|
41
|
+
ensureGlobalDir();
|
|
42
|
+
const slots = [...DEFAULT_SLOTS];
|
|
43
|
+
fs.writeFileSync(SLOTS_FILE, JSON.stringify(slots, null, 2) + '\n', 'utf-8');
|
|
44
|
+
return slots;
|
|
45
|
+
}
|
|
46
|
+
// ── registry.json ──
|
|
47
|
+
export function loadRegistry() {
|
|
48
|
+
try {
|
|
49
|
+
const raw = fs.readFileSync(REGISTRY_FILE, 'utf-8');
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
if (parsed && parsed.version === 1 && Array.isArray(parsed.slots)) {
|
|
52
|
+
// 确保 slots 数组长度正确
|
|
53
|
+
while (parsed.slots.length < MAX_SLOTS)
|
|
54
|
+
parsed.slots.push(null);
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// 不存在或损坏
|
|
60
|
+
}
|
|
61
|
+
return { version: 1, slots: new Array(MAX_SLOTS).fill(null) };
|
|
62
|
+
}
|
|
63
|
+
export function saveRegistry(registry) {
|
|
64
|
+
ensureGlobalDir();
|
|
65
|
+
const tmpPath = REGISTRY_FILE + '.tmp.' + process.pid;
|
|
66
|
+
fs.writeFileSync(tmpPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8');
|
|
67
|
+
fs.renameSync(tmpPath, REGISTRY_FILE);
|
|
68
|
+
}
|
|
69
|
+
// ── 槽位操作 ──
|
|
70
|
+
/** 清理 stale 槽位(进程已死但注册表未更新) */
|
|
71
|
+
export function cleanStaleSlots(registry) {
|
|
72
|
+
let changed = false;
|
|
73
|
+
for (let i = 0; i < registry.slots.length; i++) {
|
|
74
|
+
const entry = registry.slots[i];
|
|
75
|
+
if (entry && !isDaemonAlive(entry.pid)) {
|
|
76
|
+
registry.slots[i] = null;
|
|
77
|
+
// 清理对应的 PID 文件
|
|
78
|
+
try {
|
|
79
|
+
fs.unlinkSync(instancePidPath(i));
|
|
80
|
+
}
|
|
81
|
+
catch { /* ignore */ }
|
|
82
|
+
changed = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (changed) {
|
|
86
|
+
saveRegistry(registry);
|
|
87
|
+
}
|
|
88
|
+
return changed;
|
|
89
|
+
}
|
|
90
|
+
/** 根据 instance 名查找已分配的槽位 */
|
|
91
|
+
export function findSlotByInstance(registry, instanceId) {
|
|
92
|
+
return registry.slots.find((e) => e?.instance === instanceId) ?? null;
|
|
93
|
+
}
|
|
94
|
+
/** 分配一个空闲槽位,返回槽位索引;无空闲返回 null */
|
|
95
|
+
export function allocateSlot(registry, instanceId, pid) {
|
|
96
|
+
const slots = loadSlots();
|
|
97
|
+
const freeIndex = registry.slots.findIndex((e) => e === null);
|
|
98
|
+
if (freeIndex === -1)
|
|
99
|
+
return null;
|
|
100
|
+
const ports = slots[freeIndex];
|
|
101
|
+
const entry = {
|
|
102
|
+
index: freeIndex,
|
|
103
|
+
instance: instanceId,
|
|
104
|
+
pid,
|
|
105
|
+
wsPort: ports.wsPort,
|
|
106
|
+
devServerPort: ports.devServerPort,
|
|
107
|
+
configPort: ports.configPort,
|
|
108
|
+
startedAt: new Date().toISOString(),
|
|
109
|
+
};
|
|
110
|
+
registry.slots[freeIndex] = entry;
|
|
111
|
+
saveRegistry(registry);
|
|
112
|
+
return entry;
|
|
113
|
+
}
|
|
114
|
+
/** 释放指定 instance 的槽位 */
|
|
115
|
+
export function releaseSlot(registry, instanceId) {
|
|
116
|
+
for (let i = 0; i < registry.slots.length; i++) {
|
|
117
|
+
if (registry.slots[i]?.instance === instanceId) {
|
|
118
|
+
registry.slots[i] = null;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
saveRegistry(registry);
|
|
123
|
+
}
|
|
124
|
+
// ── 实例路径 ──
|
|
125
|
+
/** 槽位 PID 文件路径 */
|
|
126
|
+
export function instancePidPath(slotIndex) {
|
|
127
|
+
return path.join(INSTANCES_DIR, `${slotIndex}.pid`);
|
|
128
|
+
}
|
|
129
|
+
/** 槽位日志文件路径 */
|
|
130
|
+
export function instanceLogPath(slotIndex) {
|
|
131
|
+
return path.join(INSTANCES_DIR, `${slotIndex}.log`);
|
|
132
|
+
}
|
|
133
|
+
// ── 实例标识解析 ──
|
|
134
|
+
/**
|
|
135
|
+
* 解析实例标识。
|
|
136
|
+
*
|
|
137
|
+
* 优先级:REMNOTE_HEADLESS(最高,覆盖一切)> cliArg > REMNOTE_BRIDGE_INSTANCE > "default"
|
|
138
|
+
*/
|
|
139
|
+
export function resolveInstanceId(cliArg) {
|
|
140
|
+
// headless 模式覆盖 --instance,固定实例名
|
|
141
|
+
if (process.env.REMNOTE_HEADLESS === '1')
|
|
142
|
+
return 'headless';
|
|
143
|
+
if (cliArg)
|
|
144
|
+
return cliArg;
|
|
145
|
+
const fromEnv = process.env.REMNOTE_BRIDGE_INSTANCE;
|
|
146
|
+
if (fromEnv)
|
|
147
|
+
return fromEnv;
|
|
148
|
+
return 'default';
|
|
149
|
+
}
|
|
150
|
+
// ── 满载报错信息 ──
|
|
151
|
+
export function formatSlotsFullError(registry) {
|
|
152
|
+
const lines = [`错误: 已达最大实例数上限(${MAX_SLOTS}),无可用槽位。`, '', '运行中的实例:'];
|
|
153
|
+
for (const entry of registry.slots) {
|
|
154
|
+
if (entry) {
|
|
155
|
+
lines.push(` 槽位 ${entry.index}: ${entry.instance} (PID: ${entry.pid})`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
lines.push('', '请先执行 `remnote-bridge --instance <name> disconnect` 释放槽位。');
|
|
159
|
+
return lines.join('\n');
|
|
160
|
+
}
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 封装 WS 连接建立、请求发送、响应等待、超时处理的完整流程。
|
|
5
5
|
* 所有业务命令(health、read-rem、edit-rem 等)均通过此函数与 daemon 通信。
|
|
6
|
+
*
|
|
7
|
+
* 端口发现:通过 registry.json 查找当前 instance 对应的槽位端口。
|
|
6
8
|
*/
|
|
7
9
|
import WebSocket from 'ws';
|
|
8
10
|
import crypto from 'crypto';
|
|
9
|
-
import {
|
|
10
|
-
import { checkDaemon } from './pid.js';
|
|
11
|
+
import { resolveInstanceId, loadRegistry, cleanStaleSlots, findSlotByInstance } from './registry.js';
|
|
11
12
|
import { isBridgeResponse } from '../protocol.js';
|
|
12
13
|
const CONNECT_TIMEOUT_MS = 5_000;
|
|
13
14
|
const DEFAULT_RESPONSE_TIMEOUT_MS = 30_000;
|
|
@@ -26,24 +27,23 @@ export class DaemonUnreachableError extends Error {
|
|
|
26
27
|
/**
|
|
27
28
|
* 向守护进程发送请求并等待响应。
|
|
28
29
|
*
|
|
29
|
-
*
|
|
30
|
+
* 流程:解析 instance → 查 registry → 建立 WS 连接 → 发送请求 → 等待响应 → 关闭连接
|
|
30
31
|
*
|
|
31
|
-
* @throws DaemonNotRunningError —
|
|
32
|
+
* @throws DaemonNotRunningError — registry 中找不到当前 instance 或进程已死
|
|
32
33
|
* @throws DaemonUnreachableError — WS 连接失败
|
|
33
34
|
* @throws Error — daemon 返回 error 字段或响应超时
|
|
34
35
|
*/
|
|
35
36
|
export async function sendDaemonRequest(action, payload = {}, options) {
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!daemonStatus.running) {
|
|
37
|
+
const instanceId = resolveInstanceId(options?.instance);
|
|
38
|
+
const registry = loadRegistry();
|
|
39
|
+
cleanStaleSlots(registry);
|
|
40
|
+
const entry = findSlotByInstance(registry, instanceId);
|
|
41
|
+
if (!entry) {
|
|
42
42
|
throw new DaemonNotRunningError();
|
|
43
43
|
}
|
|
44
44
|
const responseTimeout = options?.timeout ?? DEFAULT_RESPONSE_TIMEOUT_MS;
|
|
45
45
|
return new Promise((resolve, reject) => {
|
|
46
|
-
const ws = new WebSocket(`ws://127.0.0.1:${
|
|
46
|
+
const ws = new WebSocket(`ws://127.0.0.1:${entry.wsPort}`);
|
|
47
47
|
const requestId = crypto.randomUUID();
|
|
48
48
|
let responseTimer = null;
|
|
49
49
|
const connectTimer = setTimeout(() => {
|
|
@@ -18,55 +18,118 @@ const MIME_TYPES = {
|
|
|
18
18
|
};
|
|
19
19
|
export class StaticServer {
|
|
20
20
|
server = null;
|
|
21
|
+
_actualPort = 0;
|
|
21
22
|
options;
|
|
23
|
+
_discovery = null;
|
|
24
|
+
/** 实际监听的端口(可能与 options.port 不同,若原端口被占用则 OS 自动分配) */
|
|
25
|
+
get actualPort() { return this._actualPort; }
|
|
22
26
|
constructor(options) {
|
|
23
27
|
this.options = options;
|
|
24
28
|
}
|
|
29
|
+
/** 动态更新 discovery 数据(daemon 启动后设置实际端口) */
|
|
30
|
+
setDiscovery(data) {
|
|
31
|
+
this._discovery = data;
|
|
32
|
+
}
|
|
25
33
|
start() {
|
|
26
34
|
const { distDir, port, onLog } = this.options;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const createHttpServer = () => http.createServer((req, res) => {
|
|
36
|
+
// CORS headers(与 webpack.config.js 一致)
|
|
37
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
38
|
+
res.setHeader('Access-Control-Allow-Headers', 'baggage, sentry-trace');
|
|
39
|
+
// OPTIONS preflight
|
|
40
|
+
if (req.method === 'OPTIONS') {
|
|
41
|
+
res.writeHead(204);
|
|
42
|
+
res.end();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const urlPath = req.url?.split('?')[0] || '/';
|
|
46
|
+
// /api/discovery 端点:返回 daemon 端口信息,供 Plugin 自动发现
|
|
47
|
+
if (urlPath === '/api/discovery') {
|
|
48
|
+
if (this._discovery) {
|
|
49
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
50
|
+
res.end(JSON.stringify(this._discovery));
|
|
37
51
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const safePrefix = distDir.endsWith(path.sep) ? distDir : distDir + path.sep;
|
|
42
|
-
if (!filePath.startsWith(safePrefix) && filePath !== distDir) {
|
|
43
|
-
res.writeHead(403);
|
|
44
|
-
res.end('Forbidden');
|
|
45
|
-
return;
|
|
52
|
+
else {
|
|
53
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
54
|
+
res.end(JSON.stringify({ error: 'discovery not ready' }));
|
|
46
55
|
}
|
|
47
|
-
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// 多实例支持:非 default 实例动态修改 manifest.json 的 id 和 name,
|
|
59
|
+
// 使 RemNote 能同时加载多个同源 Plugin(RemNote 按 id 去重)
|
|
60
|
+
if (urlPath === '/manifest.json' && this._discovery && this._discovery.instance !== 'default') {
|
|
61
|
+
const manifestPath = path.resolve(distDir, 'manifest.json');
|
|
62
|
+
fs.readFile(manifestPath, 'utf-8', (err, raw) => {
|
|
48
63
|
if (err) {
|
|
49
64
|
res.writeHead(404);
|
|
50
65
|
res.end('Not Found');
|
|
51
66
|
return;
|
|
52
67
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
try {
|
|
69
|
+
const manifest = JSON.parse(raw);
|
|
70
|
+
const suffix = this._discovery.instance;
|
|
71
|
+
manifest.id = `${manifest.id}_${suffix}`;
|
|
72
|
+
manifest.name = `${manifest.name} (${suffix})`;
|
|
73
|
+
res.writeHead(200, {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
76
|
+
});
|
|
77
|
+
res.end(JSON.stringify(manifest, null, 2));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
res.writeHead(500);
|
|
81
|
+
res.end('manifest parse error');
|
|
82
|
+
}
|
|
60
83
|
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const filePath = path.resolve(distDir, urlPath === '/' ? 'index.html' : '.' + urlPath);
|
|
87
|
+
// 防止目录遍历(resolve 规范化后,确保仍在 distDir + sep 下)
|
|
88
|
+
const safePrefix = distDir.endsWith(path.sep) ? distDir : distDir + path.sep;
|
|
89
|
+
if (!filePath.startsWith(safePrefix) && filePath !== distDir) {
|
|
90
|
+
res.writeHead(403);
|
|
91
|
+
res.end('Forbidden');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
fs.readFile(filePath, (err, data) => {
|
|
95
|
+
if (err) {
|
|
96
|
+
res.writeHead(404);
|
|
97
|
+
res.end('Not Found');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const ext = path.extname(filePath);
|
|
101
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
102
|
+
res.writeHead(200, {
|
|
103
|
+
'Content-Type': contentType,
|
|
104
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
105
|
+
});
|
|
106
|
+
res.end(data);
|
|
61
107
|
});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
108
|
+
});
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const tryListen = (listenPort) => {
|
|
111
|
+
this.server = createHttpServer();
|
|
112
|
+
this.server.on('error', (err) => {
|
|
113
|
+
if (err.code === 'EADDRINUSE' && listenPort !== 0) {
|
|
114
|
+
onLog?.(`[static-server] 端口 ${listenPort} 被占用,尝试自动分配...`, 'warn');
|
|
115
|
+
this.server = null;
|
|
116
|
+
tryListen(0);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
onLog?.(`[static-server] 启动失败: ${err.message}`, 'error');
|
|
120
|
+
reject(err);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
this.server.listen(listenPort, '127.0.0.1', () => {
|
|
124
|
+
this._actualPort = this.server.address().port;
|
|
125
|
+
if (this._actualPort !== port) {
|
|
126
|
+
onLog?.(`[static-server] 端口 ${port} 被占用,自动分配到 ${this._actualPort}`, 'warn');
|
|
127
|
+
}
|
|
128
|
+
onLog?.(`[static-server] 已启动 http://127.0.0.1:${this._actualPort} (serving ${distDir})`, 'info');
|
|
129
|
+
resolve();
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
tryListen(port);
|
|
70
133
|
});
|
|
71
134
|
}
|
|
72
135
|
stop() {
|