annotatable-html 0.0.0-stage → 1.45.3
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/LICENSE +21 -0
- package/README.md +352 -2
- package/agent/annot_answer.js +7 -0
- package/agent/annot_edit.js +6 -0
- package/agent/annot_poll.js +19 -0
- package/agent/annot_read.js +5 -0
- package/agent/annot_reply.js +50 -0
- package/bin/cli.js +323 -0
- package/lib/claude.js +242 -0
- package/lib/codex-desktop.js +130 -0
- package/lib/codex-headless.js +129 -0
- package/lib/codex.js +216 -0
- package/lib/core.js +2206 -0
- package/lib/document.js +56 -0
- package/lib/duty-process.js +190 -0
- package/lib/duty-worker.js +5 -0
- package/lib/duty.js +334 -0
- package/lib/lang-en.js +462 -0
- package/lib/opencode.js +195 -0
- package/lib/poll-process.js +42 -0
- package/lib/serve.js +318 -0
- package/lib/vendor/marked.cjs +2811 -0
- package/lib/vendor/temml.cjs +14710 -0
- package/package.json +51 -6
- package/templates/SKILL.md +53 -0
- package/templates/annotatable-doc.html +5702 -0
- package/templates/annotatable.theme.css +28 -0
- package/templates/commands/html4agent.md +6 -0
- package/templates/opencode/annotatable-html.js +12 -0
- package/templates/references/compatibility.md +14 -0
- package/templates/references/hosts.md +47 -0
- package/templates/references/operations.md +37 -0
- package/templates/theme-studio.html +330 -0
package/lib/opencode.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* OpenCode's documented HTTP session API. No SDK dependency or provider credentials are needed. */
|
|
4
|
+
var http = require('http');
|
|
5
|
+
var https = require('https');
|
|
6
|
+
var runPoll = require('./poll-process.js').runPoll;
|
|
7
|
+
|
|
8
|
+
function request(base, route, method, body, directory, options) {
|
|
9
|
+
var url = new URL(base);
|
|
10
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return Promise.reject(new Error('OpenCode 地址必须使用 http 或 https'));
|
|
11
|
+
if (url.username || url.password) return Promise.reject(new Error('OpenCode 服务密码请放在 OPENCODE_SERVER_PASSWORD 中'));
|
|
12
|
+
url.pathname = url.pathname.replace(/\/$/, '') + route;
|
|
13
|
+
url.search = '';
|
|
14
|
+
url.hash = '';
|
|
15
|
+
var headers = { Accept: 'application/json' };
|
|
16
|
+
if (directory) headers['x-opencode-directory'] = encodeURIComponent(directory);
|
|
17
|
+
if (process.env.OPENCODE_SERVER_PASSWORD) headers.Authorization = 'Basic ' + Buffer.from(
|
|
18
|
+
(process.env.OPENCODE_SERVER_USERNAME || 'opencode') + ':' + process.env.OPENCODE_SERVER_PASSWORD).toString('base64');
|
|
19
|
+
var data = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
20
|
+
if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = data.length; }
|
|
21
|
+
return new Promise(function (resolve, reject) {
|
|
22
|
+
var req = (url.protocol === 'https:' ? https : http).request(url, { method: method, headers: headers, agent: false }, function (res) {
|
|
23
|
+
var chunks = [], size = 0;
|
|
24
|
+
res.on('error', reject);
|
|
25
|
+
res.on('data', function (chunk) {
|
|
26
|
+
size += chunk.length;
|
|
27
|
+
if (size > 1024 * 1024) { req.destroy(new Error('OpenCode 响应过大')); return; }
|
|
28
|
+
chunks.push(chunk);
|
|
29
|
+
});
|
|
30
|
+
res.on('end', function () {
|
|
31
|
+
if (res.statusCode < 200 || res.statusCode >= 300) { reject(new Error('OpenCode HTTP ' + res.statusCode + ':' + route)); return; }
|
|
32
|
+
var text = Buffer.concat(chunks).toString('utf8');
|
|
33
|
+
if (!text) { resolve(null); return; }
|
|
34
|
+
try { resolve(JSON.parse(text)); } catch (_) { reject(new Error('OpenCode 返回了无效 JSON')); }
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
req.setTimeout(options && options.timeoutMs || 10000, function () { req.destroy(new Error('OpenCode 请求超时')); });
|
|
38
|
+
req.on('error', reject);
|
|
39
|
+
req.end(data);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function pollAnnotations(htmlPath, opts) {
|
|
44
|
+
opts = opts || {};
|
|
45
|
+
var base = opts.opencodeUrl || process.env.ANNOTATABLE_OPENCODE_SERVER_URL;
|
|
46
|
+
var session = opts.opencodeSession || process.env.ANNOTATABLE_OPENCODE_SESSION_ID;
|
|
47
|
+
var directory = process.env.ANNOTATABLE_OPENCODE_DIRECTORY || process.cwd();
|
|
48
|
+
if (!base || !session) throw new Error('缺少 OpenCode 会话信息:新开已安装上下文插件的会话,或指定 --opencode-url 和 --opencode-session');
|
|
49
|
+
var route = '/session/' + encodeURIComponent(session);
|
|
50
|
+
var info = await request(base, route, 'GET', undefined, directory);
|
|
51
|
+
if (!info || info.id !== session) throw new Error('OpenCode 返回的会话与目标不符');
|
|
52
|
+
var result = await runPoll(htmlPath, opts);
|
|
53
|
+
if (result.hit) {
|
|
54
|
+
await request(base, route + '/prompt_async', 'POST', { parts: [{ type: 'text', synthetic: true,
|
|
55
|
+
text: '[annotatable-html 监听事件]\nPOLL_HIT ' + JSON.stringify(result.hit) +
|
|
56
|
+
'\n按批注工作流读取完整对话并处理。完成后重新挂同一文件的 poll --opencode。'
|
|
57
|
+
}] }, directory);
|
|
58
|
+
console.log('OPENCODE_NOTIFIED ' + JSON.stringify({ sessionId: session, file: result.hit.file }));
|
|
59
|
+
}
|
|
60
|
+
process.exitCode = result.code;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function dutySettings(base, id, directory) {
|
|
64
|
+
var session = await request(base, '/session/' + encodeURIComponent(id), 'GET', undefined, directory);
|
|
65
|
+
if (!session || session.id !== id || !session.directory) throw new Error('OpenCode 返回其他会话或缺少目录');
|
|
66
|
+
var cwd = session.directory;
|
|
67
|
+
var config = await request(base, '/config', 'GET', undefined, cwd);
|
|
68
|
+
var messages = await request(base, '/session/' + encodeURIComponent(id) + '/message', 'GET', undefined, cwd);
|
|
69
|
+
var last = messages.slice().reverse().find(m => m.info && m.info.role === 'user' && m.info.model);
|
|
70
|
+
if (!last || !last.info.model.providerID || !last.info.model.modelID) throw new Error('OpenCode 会话缺少已选模型;先在原会话完成一轮');
|
|
71
|
+
var user = last.info, agent = user.agent || 'build';
|
|
72
|
+
var provider = config.provider && config.provider[user.model.providerID] || {};
|
|
73
|
+
var baseURL = provider.options && provider.options.baseURL || null;
|
|
74
|
+
if (baseURL) { var endpoint = new URL(baseURL); if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error('OpenCode 提供方地址不能含内嵌凭据或查询参数'); }
|
|
75
|
+
return { cwd: cwd, model: user.model, provider: { npm: provider.npm || null, baseURL: baseURL }, agent: agent, variant: user.variant || null, tools: user.tools || null,
|
|
76
|
+
permission: config.permission || {}, sessionPermission: session.permission || null,
|
|
77
|
+
agentPermission: config.agent && config.agent[agent] && config.agent[agent].permission || {} };
|
|
78
|
+
}
|
|
79
|
+
async function dutyBinding(file, options) {
|
|
80
|
+
var processes = require('./duty-process');
|
|
81
|
+
var base = options.opencodeUrl || process.env.ANNOTATABLE_OPENCODE_SERVER_URL;
|
|
82
|
+
var id = options.opencodeSession || process.env.ANNOTATABLE_OPENCODE_SESSION_ID;
|
|
83
|
+
if (!base || !/^ses_[a-z0-9]+$/i.test(id || '')) throw new Error('OpenCode 续接需要原服务地址和会话编号');
|
|
84
|
+
var url = new URL(base);
|
|
85
|
+
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname) || url.username || url.password) throw new Error('自动续接只支持无内嵌密码的本机 OpenCode HTTP 服务');
|
|
86
|
+
var owner = await processes.portOwner(Number(url.port || 80));
|
|
87
|
+
if (!/^opencode(?:\.exe)?$/i.test(owner.Name)) throw new Error('无法确认 OpenCode 服务进程');
|
|
88
|
+
var settings = await dutySettings(base, id, options.opencodeDirectory || process.env.ANNOTATABLE_OPENCODE_DIRECTORY);
|
|
89
|
+
var paths = await request(base, '/path', 'GET');
|
|
90
|
+
return { threadId: id, binary: options.opencodeBin || owner.ExecutablePath, settings: settings,
|
|
91
|
+
opencode: { url: url.origin, owners: [processes.identity(owner)], serverCwd: paths.directory,
|
|
92
|
+
paths: { home: paths.home, config: paths.config, state: paths.state },
|
|
93
|
+
files: processes.configStamps(settings.cwd, ['opencode.json', 'opencode.jsonc', '.opencode/opencode.json', '.opencode/opencode.jsonc'],
|
|
94
|
+
[require('path').join(paths.config, 'opencode.json'), require('path').join(paths.config, 'opencode.jsonc')].concat(process.env.OPENCODE_CONFIG ? [require('path').resolve(paths.directory, process.env.OPENCODE_CONFIG)] : [])) } };
|
|
95
|
+
}
|
|
96
|
+
function createDutyAdapter(getBinding, updateBinding) {
|
|
97
|
+
var processes = require('./duty-process'), duty = require('./duty');
|
|
98
|
+
async function inspect(base, process) {
|
|
99
|
+
var b = getBinding();
|
|
100
|
+
if (!process || duty.canonicalPath(process.ExecutablePath) !== duty.canonicalPath(b.binary)) return null;
|
|
101
|
+
try {
|
|
102
|
+
var paths = await request(base, '/path', 'GET', undefined, undefined, { timeoutMs: 1000 });
|
|
103
|
+
if (duty.canonicalPath(paths.directory) !== duty.canonicalPath(b.opencode.serverCwd)) return null;
|
|
104
|
+
var s = await request(base, '/session/' + b.threadId, 'GET', undefined, b.settings.cwd, { timeoutMs: 1000 });
|
|
105
|
+
if (s && s.id === b.threadId && duty.canonicalPath(s.directory) === duty.canonicalPath(b.settings.cwd)) return { url: base, process: process };
|
|
106
|
+
} catch (_) {}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
async function candidates(excludePid) {
|
|
110
|
+
var b = getBinding(), found = [], listeners = await processes.listeners(b.binary);
|
|
111
|
+
for (var listener of (Array.isArray(listeners) ? listeners : listeners ? [listeners] : [])) {
|
|
112
|
+
if (listener.process.ProcessId === excludePid) continue;
|
|
113
|
+
for (var port of listener.ports) {
|
|
114
|
+
var value = await inspect('http://127.0.0.1:' + port, listener.process); if (value) found.push(value);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
async function online() {
|
|
120
|
+
var b = getBinding(), owner = await processes.processInfo(b.opencode.owners[0].ProcessId), selected;
|
|
121
|
+
if (processes.sameProcess(b.opencode.owners[0], owner)) selected = await inspect(b.opencode.url, owner);
|
|
122
|
+
else { var found = await candidates(); if (found.length === 1) selected = found[0]; }
|
|
123
|
+
if (!selected) return null;
|
|
124
|
+
if (!processes.sameProcess(b.opencode.owners[0], selected.process)) updateBinding(Object.assign({}, b,
|
|
125
|
+
{ opencode: Object.assign({}, b.opencode, { url: selected.url, owners: [processes.identity(selected.process)] }) }));
|
|
126
|
+
var states = await request(selected.url, '/session/status', 'GET', undefined, b.settings.cwd);
|
|
127
|
+
return { available: true, active: !!(states[b.threadId] && states[b.threadId].type !== 'idle'), notify: hit =>
|
|
128
|
+
request(selected.url, '/session/' + b.threadId + '/prompt_async', 'POST', { parts: [{ type: 'text', synthetic: true, text: duty.prompt(b, hit) }] }, b.settings.cwd) };
|
|
129
|
+
}
|
|
130
|
+
async function released(excludePid) {
|
|
131
|
+
if (!(await processes.ownersExited(getBinding().opencode.owners)).released) return { released: false, reason: 'opencode-server-process-alive' };
|
|
132
|
+
if ((await candidates(excludePid)).length) return { released: false, reason: 'opencode-session-server-returned' };
|
|
133
|
+
return { released: true, reason: 'opencode-session-server-exited' };
|
|
134
|
+
}
|
|
135
|
+
async function run(hit, onStart) {
|
|
136
|
+
var b = getBinding(), cp = require('child_process');
|
|
137
|
+
if (b.opencode.files && !require('util').isDeepStrictEqual(b.opencode.files.map(x => processes.fileStamp(x.file)), b.opencode.files)) throw new Error('OpenCode 配置已改变,请重新注册');
|
|
138
|
+
if (!(await released()).released) throw new Error('OpenCode 原服务尚未退出');
|
|
139
|
+
var url = new URL(b.opencode.url), child = cp.spawn(b.binary, ['serve', '--port', url.port || '80', '--hostname', '127.0.0.1'],
|
|
140
|
+
{ cwd: b.opencode.serverCwd, env: processes.agentEnv(), windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
141
|
+
child.stdout.resume(); child.stderr.resume();
|
|
142
|
+
var record = { host: 'opencode', threadId: b.threadId, pid: child.pid, startedAt: Date.now() }, exited = false;
|
|
143
|
+
var closed = new Promise(resolve => child.once('close', code => { exited = true; resolve(code); }));
|
|
144
|
+
var spawnError; child.once('error', error => { spawnError = error; });
|
|
145
|
+
if (onStart) onStart(record);
|
|
146
|
+
var base = url.origin, route = '/session/' + b.threadId;
|
|
147
|
+
var api = (p, method, body) => request(base, p, method || 'GET', body, b.settings.cwd);
|
|
148
|
+
try {
|
|
149
|
+
var until = Date.now() + 30000;
|
|
150
|
+
for (;;) {
|
|
151
|
+
if (spawnError) throw spawnError;
|
|
152
|
+
if (exited) throw new Error('OpenCode 无头服务启动失败,保留待办');
|
|
153
|
+
try { var health = await api('/global/health'); if (health.healthy) break; } catch (_) {}
|
|
154
|
+
if (Date.now() > until) throw new Error('OpenCode 无头服务未确认启动');
|
|
155
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
156
|
+
}
|
|
157
|
+
var owner = await processes.portOwner(Number(url.port || 80));
|
|
158
|
+
if (owner.ProcessId !== child.pid) throw new Error('服务端口属于其他进程,不接管');
|
|
159
|
+
var settings = await dutySettings(base, b.threadId, b.settings.cwd), paths = await request(base, '/path', 'GET');
|
|
160
|
+
if (JSON.stringify(settings) !== JSON.stringify(b.settings) ||
|
|
161
|
+
JSON.stringify({ home: paths.home, config: paths.config, state: paths.state }) !== JSON.stringify(b.opencode.paths)) throw new Error('OpenCode 模型、权限或存储位置已改变,请重新注册');
|
|
162
|
+
record.restored = settings;
|
|
163
|
+
if (!(await released(child.pid)).released) throw new Error('OpenCode 原会话已重开,暂停无头执行');
|
|
164
|
+
var before = new Set((await api(route + '/message')).map(m => m.info.id));
|
|
165
|
+
await api(route + '/prompt_async', 'POST', { model: settings.model, agent: settings.agent, variant: settings.variant || undefined,
|
|
166
|
+
tools: settings.tools || undefined, parts: [{ type: 'text', synthetic: true, text: duty.prompt(b, hit, { headless: true }) }] });
|
|
167
|
+
until = Date.now() + 300000;
|
|
168
|
+
for (;;) {
|
|
169
|
+
for (var kind of ['permission', 'question']) {
|
|
170
|
+
var requests = await api('/' + kind);
|
|
171
|
+
for (var item of requests.filter(x => x.sessionID === b.threadId)) {
|
|
172
|
+
await api('/' + kind + '/' + item.id + (kind === 'permission' ? '/reply' : '/reject'), 'POST', kind === 'permission' ? { reply: 'reject' } : {});
|
|
173
|
+
throw new Error('OpenCode 需要人工审批或补充信息,保留待办');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
var messages = await api(route + '/message'), fresh = messages.filter(m => !before.has(m.info.id) && m.info.role === 'assistant');
|
|
177
|
+
var failed = fresh.find(m => m.info.error);
|
|
178
|
+
if (failed) throw new Error('OpenCode 模型返回 ' + failed.info.error.name);
|
|
179
|
+
var states = await api('/session/status'), last = fresh[fresh.length - 1];
|
|
180
|
+
if (states[b.threadId] && states[b.threadId].action && states[b.threadId].action.reason === 'free_tier_limit') throw new Error('原 OpenCode 模型额度已耗尽,保留待办');
|
|
181
|
+
if (last && last.info.time.completed && (!states[b.threadId] || states[b.threadId].type === 'idle')) {
|
|
182
|
+
record.turnId = last.info.id; record.outcome = 'completed'; return record;
|
|
183
|
+
}
|
|
184
|
+
if (Date.now() > until) throw new Error('OpenCode 回复超时,保留待办');
|
|
185
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
try { if (!exited) await api(route + '/abort', 'POST'); } catch (_) {}
|
|
189
|
+
record.outcome = 'failed'; record.error = error.message; throw Object.assign(error, { dutyRun: record });
|
|
190
|
+
} finally { if (!exited) child.kill(); await closed; record.finishedAt = Date.now(); }
|
|
191
|
+
}
|
|
192
|
+
return { online: online, released: released, run: run, close: () => {} };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = { request: request, pollAnnotations: pollAnnotations, dutySettings: dutySettings, dutyBinding: dutyBinding, createDutyAdapter: createDutyAdapter };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* Run the existing single-use poll and capture only its structured hit.
|
|
4
|
+
* Shared by optional host notification transports; the core polling rules stay unchanged. */
|
|
5
|
+
var path = require('path');
|
|
6
|
+
var cp = require('child_process');
|
|
7
|
+
var readline = require('readline');
|
|
8
|
+
|
|
9
|
+
function runPoll(htmlPath, opts) {
|
|
10
|
+
opts = opts || {};
|
|
11
|
+
var argv = [path.join(__dirname, '..', 'agent', 'annot_poll.js'), path.resolve(htmlPath)];
|
|
12
|
+
[['--timeout', opts.timeout], ['--interval', opts.interval], ['--exit-code', opts.exitCode],
|
|
13
|
+
['--exit-code-timeout', opts.timeoutExitCode]].forEach(function (item) {
|
|
14
|
+
if (item[1] !== undefined) argv.push(item[0], String(item[1]));
|
|
15
|
+
});
|
|
16
|
+
return new Promise(function (resolve, reject) {
|
|
17
|
+
var hit;
|
|
18
|
+
var child = cp.spawn(process.execPath, argv, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
19
|
+
var stop = function () { child.kill(); };
|
|
20
|
+
process.once('SIGINT', stop);
|
|
21
|
+
process.once('SIGTERM', stop);
|
|
22
|
+
child.stdout.pipe(process.stdout);
|
|
23
|
+
child.stderr.pipe(process.stderr);
|
|
24
|
+
var lines = readline.createInterface({ input: child.stdout });
|
|
25
|
+
lines.on('line', function (line) {
|
|
26
|
+
if (line.indexOf('POLL_HIT ') !== 0) return;
|
|
27
|
+
try {
|
|
28
|
+
var payload = JSON.parse(line.slice(9));
|
|
29
|
+
if (path.resolve(payload.file) === path.resolve(htmlPath)) hit = payload;
|
|
30
|
+
} catch (_) { /* Ignore unstructured output. */ }
|
|
31
|
+
});
|
|
32
|
+
child.once('error', reject);
|
|
33
|
+
child.once('close', function (code) {
|
|
34
|
+
lines.close();
|
|
35
|
+
process.removeListener('SIGINT', stop);
|
|
36
|
+
process.removeListener('SIGTERM', stop);
|
|
37
|
+
resolve({ code: code === null ? 1 : code, hit: hit });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { runPoll: runPoll };
|
package/lib/serve.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* annotatable serve —— 本地伴随服务器
|
|
3
|
+
* 静态伺服目录 + 受控写回端点,让可批注 HTML 在 localhost 上获得
|
|
4
|
+
* 零点击、零授权的全自动保存(同源 POST,仅监听 127.0.0.1)。 */
|
|
5
|
+
|
|
6
|
+
var http = require('http');
|
|
7
|
+
var fs = require('fs');
|
|
8
|
+
var path = require('path');
|
|
9
|
+
var os = require('os');
|
|
10
|
+
var core = require('./core'); /* v1.26.0:saveDocHtml(锁内 rev 复验+原子写+记票)与 readSideHist(/__annot/hist) */
|
|
11
|
+
|
|
12
|
+
var MIME = {
|
|
13
|
+
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8',
|
|
14
|
+
'.js': 'text/javascript; charset=utf-8', '.json': 'application/json',
|
|
15
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
16
|
+
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
17
|
+
'.md': 'text/plain; charset=utf-8'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/* 防本机 CSRF:仅接受 JSON content-type(跨站简单请求会被此拦下),
|
|
21
|
+
且 Origin 若存在必须为空(file://)或 localhost */
|
|
22
|
+
function sameOriginGuard(req, res) {
|
|
23
|
+
var ct = (req.headers['content-type'] || '');
|
|
24
|
+
var og = (req.headers['origin'] || 'null');
|
|
25
|
+
if (ct.indexOf('application/json') === -1) { res.writeHead(403); res.end('bad content-type'); return false; }
|
|
26
|
+
if (og !== 'null' && og.indexOf('http://localhost') !== 0 && og.indexOf('http://127.0.0.1') !== 0) { res.writeHead(403); res.end('bad origin'); return false; }
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
/* 根目录边界校验与请求路径解析(withinRoot/safeServePath 在 start() 内定义——root 是每次启动的伺服根) */
|
|
30
|
+
function readJsonBody(req, res, cap, cb) {
|
|
31
|
+
/* v1.28.1:必须按 Buffer 累积、end 时整体解码——旧写法对每个 chunk 独立 toString,
|
|
32
|
+
多字节 UTF-8 字符恰跨 chunk 边界即被打成 U+FFFD(调研页批语「划线」变「���」、
|
|
33
|
+
模板装饰 ═══ 变乱码实证;打中中文批注文本=静默数据腐蚀)。cap 按字节计。 */
|
|
34
|
+
var chunks = [], total = 0, dead = false;
|
|
35
|
+
req.on('data', function (c) {
|
|
36
|
+
if (dead) return;
|
|
37
|
+
chunks.push(c); total += c.length;
|
|
38
|
+
if (total > cap) { dead = true; req.destroy(); }
|
|
39
|
+
});
|
|
40
|
+
req.on('end', function () {
|
|
41
|
+
if (dead) return;
|
|
42
|
+
try { cb(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
43
|
+
catch (e) { res.writeHead(400); res.end(String(e.message || e)); }
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* 探测占用者是不是自家 serve(POST /__annot/ping 应 pong);1s 超时/连不上按「不是」处理 */
|
|
48
|
+
function probeSelf(port, cb) {
|
|
49
|
+
var req = http.request({ host: '127.0.0.1', port: port, path: '/__annot/ping', method: 'POST', timeout: 1000 }, function (res) {
|
|
50
|
+
var b = '';
|
|
51
|
+
res.on('data', function (c) { b += c; });
|
|
52
|
+
res.on('end', function () { cb(b.indexOf('pong') !== -1); });
|
|
53
|
+
});
|
|
54
|
+
req.on('timeout', function () { req.destroy(); cb(false); });
|
|
55
|
+
req.on('error', function () { cb(false); });
|
|
56
|
+
req.end();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function start(root, port, opts) {
|
|
60
|
+
root = path.resolve(root || process.cwd());
|
|
61
|
+
try { core.syncKitCode(root, path.join(__dirname, '..')); } catch (e) { /* kit 同步尽力而为 */ } // v1.38.4 值守开场必经 serve——顺手把文档侧过期 kit 拷贝拉齐(issue#7 根因)
|
|
62
|
+
if (port === undefined || port === null || port === '' || isNaN(port)) port = 8973; // --port 0 = 系统挑空闲端口,是合法值别当缺省吞掉
|
|
63
|
+
var studioProvider = opts && typeof opts.studioProvider === 'function' ? opts.studioProvider : null;
|
|
64
|
+
|
|
65
|
+
/* 根目录边界校验:resolve 后必须仍落在伺服根内(防 ../ 越界) */
|
|
66
|
+
function withinRoot(p) {
|
|
67
|
+
return p === root || p.indexOf(root + path.sep) === 0;
|
|
68
|
+
}
|
|
69
|
+
/* 请求路径 → 伺服根内绝对路径;越界返回 null */
|
|
70
|
+
function safeServePath(rel) {
|
|
71
|
+
var p = path.resolve(root, '.' + rel);
|
|
72
|
+
return withinRoot(p) ? p : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
var server = http.createServer(function (req, res) {
|
|
76
|
+
var u;
|
|
77
|
+
try { u = decodeURIComponent(req.url.split('?')[0]); } catch (e) { res.writeHead(400); res.end('bad url'); return; }
|
|
78
|
+
|
|
79
|
+
/* 心跳探测:页面据此判断处于服务器模式 */
|
|
80
|
+
if (req.method === 'POST' && u === '/__annot/ping') { res.writeHead(200); res.end('pong'); return; }
|
|
81
|
+
|
|
82
|
+
// A new local agent can resolve one user-specified page without a global duty list.
|
|
83
|
+
if (req.method === 'GET' && u === '/__annot/context') {
|
|
84
|
+
var contextPath;
|
|
85
|
+
try { contextPath = new URL('http://x' + req.url).searchParams.get('path') || ''; }
|
|
86
|
+
catch (contextError) { res.writeHead(400); res.end('bad path'); return; }
|
|
87
|
+
var contextFile = contextPath.charAt(0) === '/' && safeServePath(contextPath);
|
|
88
|
+
if (!contextFile || !/\.html?$/i.test(contextFile)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
89
|
+
fs.readFile(contextFile, 'utf8', function (err, html) {
|
|
90
|
+
if (err) { res.writeHead(404); res.end('not found'); return; }
|
|
91
|
+
var contextStore;
|
|
92
|
+
try { contextStore = core.readStore(html); } catch (parseError) { /* invalid document */ }
|
|
93
|
+
if (!contextStore) { res.writeHead(422); res.end('not an annotatable document'); return; }
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
95
|
+
res.end(JSON.stringify({ service: 'annotatable-html', version: core.pkgVersion(path.join(__dirname, '..')),
|
|
96
|
+
root: root, file: contextFile, docId: core.docIdOf(html) }));
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* live-sync 探测(v1.21.0):页面轮询本端点拿 rev/mtime,文件变了才热载,
|
|
102
|
+
免去每秒全文拉取。路径先过 safeServePath 边界校验,再限 .html。 */
|
|
103
|
+
if (req.method === 'GET' && u === '/__annot/rev') {
|
|
104
|
+
var qs;
|
|
105
|
+
try { qs = new URL('http://x' + req.url).searchParams.get('path') || ''; } catch (e0) { qs = ''; }
|
|
106
|
+
try { qs = decodeURIComponent(qs); } catch (e1) { /* 保持原样 */ }
|
|
107
|
+
var rfp = safeServePath(qs);
|
|
108
|
+
if (!rfp) { res.writeHead(403); res.end('forbidden'); return; }
|
|
109
|
+
if (!/\.html?$/i.test(rfp)) { res.writeHead(403); res.end('只允许查询 .html'); return; }
|
|
110
|
+
fs.readFile(rfp, 'utf8', function (re1, txt) {
|
|
111
|
+
if (re1) { res.writeHead(404); res.end('not found'); return; }
|
|
112
|
+
var rm = txt.match(/"rev"\s*:\s*(\d+)/);
|
|
113
|
+
var replyRev = function (st) {
|
|
114
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
115
|
+
res.end(JSON.stringify({ rev: rm ? parseInt(rm[1], 10) : 0, mtime: st ? st.mtimeMs : 0, size: st ? st.size : txt.length }));
|
|
116
|
+
};
|
|
117
|
+
fs.stat(rfp, function (re3, st) { replyRev(re3 ? null : st); });
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* 值守心跳查询(v1.33.0,issue#6):读 .annotatable/<file>.watch.json 报 watcher 存活与心跳年龄。
|
|
123
|
+
只读端点;路径过 safeServePath 边界校验再限 .html,与 /__annot/rev 同一边界面。
|
|
124
|
+
页面值守灯据此渲染 在岗/心跳超龄/换班中/无人值守,让人在朝深渊喊话前先看见灯是红的。 */
|
|
125
|
+
if (req.method === 'GET' && u === '/__annot/status') {
|
|
126
|
+
var ws0;
|
|
127
|
+
try { ws0 = new URL('http://x' + req.url).searchParams.get('path') || ''; } catch (e4) { ws0 = ''; }
|
|
128
|
+
try { ws0 = decodeURIComponent(ws0); } catch (e5) { /* 保持原样 */ }
|
|
129
|
+
var wsp = safeServePath(ws0);
|
|
130
|
+
if (!wsp) { res.writeHead(403); res.end('forbidden'); return; }
|
|
131
|
+
if (!/\.html?$/i.test(wsp)) { res.writeHead(403); res.end('只允许查询 .html'); return; }
|
|
132
|
+
var replyStatus = function (o) {
|
|
133
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
134
|
+
res.end(JSON.stringify(o));
|
|
135
|
+
};
|
|
136
|
+
fs.readFile(path.join(path.dirname(wsp), '.annotatable', path.basename(wsp) + '.watch.json'), 'utf8', function (we4, wt4) {
|
|
137
|
+
if (we4) { replyStatus({ watching: false, state: 'none', ageSec: -1 }); return; }
|
|
138
|
+
try {
|
|
139
|
+
var wj = JSON.parse(wt4);
|
|
140
|
+
replyStatus({
|
|
141
|
+
watching: wj.state === 'watching',
|
|
142
|
+
state: wj.state || 'none',
|
|
143
|
+
ageSec: Math.max(0, Math.round((Date.now() - (wj.lastBeat || 0)) / 1000)),
|
|
144
|
+
pid: wj.pid || 0, startedAt: wj.startedAt || 0
|
|
145
|
+
});
|
|
146
|
+
} catch (e6) { replyStatus({ watching: false, state: 'none', ageSec: -1 }); } // 撕裂心跳 ≈ 无心跳,按保守渲染
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* 主题工坊页(v1.17.0):仅 annotatable studio 启动时提供,内存直出不落盘 */
|
|
152
|
+
if (req.method === 'GET' && u === '/__annot/studio') {
|
|
153
|
+
if (!studioProvider) { res.writeHead(404); res.end('studio 未随 annotatable studio 启动'); return; }
|
|
154
|
+
try {
|
|
155
|
+
res.writeHead(200, { 'Content-Type': MIME['.html'] });
|
|
156
|
+
res.end(studioProvider());
|
|
157
|
+
} catch (e) { res.writeHead(500); res.end(String(e.message || e)); }
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* 主题工坊保存(v1.17.0):仅两种固定目标(伺服根目录 / ~/.annotatable),
|
|
162
|
+
文件名服务端写死,无路径遍历面 */
|
|
163
|
+
if (req.method === 'POST' && u === '/__annot/theme') {
|
|
164
|
+
if (!sameOriginGuard(req, res)) return;
|
|
165
|
+
readJsonBody(req, res, 2 * 1024 * 1024, function (o) {
|
|
166
|
+
try {
|
|
167
|
+
if (!o || typeof o.css !== 'string' || (o.css && o.css.indexOf('\x00') !== -1)) throw new Error('bad payload');
|
|
168
|
+
if (o.css.length > 1024 * 1024) throw new Error('css 过大');
|
|
169
|
+
var target = o.scope === 'global'
|
|
170
|
+
? path.join(os.homedir(), '.annotatable', 'annotatable.theme.css')
|
|
171
|
+
: path.join(root, 'annotatable.theme.css');
|
|
172
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
173
|
+
fs.writeFileSync(target, o.css);
|
|
174
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
175
|
+
res.end(JSON.stringify({ ok: true, path: target }));
|
|
176
|
+
} catch (e) {
|
|
177
|
+
res.writeHead(400); res.end(String(e.message || e));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* 受控写回:仅伺服目录内、仅 .html,防路径越界 */
|
|
184
|
+
if (req.method === 'POST' && u === '/__annot/save') {
|
|
185
|
+
if (!sameOriginGuard(req, res)) return;
|
|
186
|
+
readJsonBody(req, res, 20 * 1024 * 1024, function (o) {
|
|
187
|
+
try {
|
|
188
|
+
if (!o || typeof o.path !== 'string' || typeof o.html !== 'string') throw new Error('bad payload');
|
|
189
|
+
var rel = o.path;
|
|
190
|
+
try { rel = decodeURIComponent(rel); } catch (e0) { /* 保持原样 */ } // location.pathname 是百分号编码,中文文件名须解码
|
|
191
|
+
var target = path.resolve(root, '.' + rel);
|
|
192
|
+
if (target !== root && target.indexOf(root + path.sep) !== 0) throw new Error('路径越界');
|
|
193
|
+
if (!/\.html?$/i.test(target)) throw new Error('只允许写入 .html');
|
|
194
|
+
if (!fs.existsSync(target)) throw new Error('目标文件不在伺服目录中');
|
|
195
|
+
/* v1.26.0:rev 复验 + 文档锁 + 原子写 + 写票(saveDocHtml 内部完成)。
|
|
196
|
+
baseRev 缺省 = 老页面,跳过校验按现状放行;rev 不符 → 409+diskRev,
|
|
197
|
+
浏览器收到后自动重基合并重试(serverSaveNow 的 409 分支)。 */
|
|
198
|
+
var sr = core.saveDocHtml(target, o.html, (typeof o.rev === 'number' ? o.rev : null), 'serve');
|
|
199
|
+
if (!sr.ok) {
|
|
200
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
201
|
+
res.end(JSON.stringify({ error: 'rev-conflict', diskRev: sr.diskRev }));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
res.writeHead(200); res.end('saved');
|
|
205
|
+
} catch (e) {
|
|
206
|
+
res.writeHead(400); res.end(String(e.message || e));
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* v1.26.0 读侧车账本(修待改进⑬半边):serve 模式页面的「回退恢复原文」/回滚
|
|
213
|
+
由此取历史快照(FS Access 模式浏览器直读 .annotatable/,不走这里)。
|
|
214
|
+
只读、限伺服目录内 .html,core.histForFile 内部按 DOC_ID 归属校验防互染。 */
|
|
215
|
+
if (req.method === 'GET' && u === '/__annot/hist') {
|
|
216
|
+
var hq;
|
|
217
|
+
try { hq = new URL('http://x' + req.url).searchParams.get('path') || ''; } catch (e1) { hq = ''; }
|
|
218
|
+
try { hq = decodeURIComponent(hq); } catch (e2) { /* 保持原样 */ }
|
|
219
|
+
var hfp = safeServePath(hq);
|
|
220
|
+
if (!hfp) { res.writeHead(403); res.end('forbidden'); return; }
|
|
221
|
+
if (!/\.html?$/i.test(hfp)) { res.writeHead(403); res.end('只允许查询 .html'); return; }
|
|
222
|
+
try {
|
|
223
|
+
var hj = JSON.stringify(core.histForFile(hfp)); // 先算后写头:histForFile 抛错(如坏路径 ENOENT)走 404,不打崩 serve
|
|
224
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
225
|
+
res.end(hj);
|
|
226
|
+
} catch (e3) {
|
|
227
|
+
if (!res.headersSent) { res.writeHead(404); res.end('not found'); } else res.end();
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* v1.31.0 写侧车账本(serve 页面档案缺失收口):此前 serve 模式只读不写,浏览器保存的
|
|
233
|
+
全量历史(含批注层快照/大纸条)无处落盘——版本历史面板「缺批注层快照」、回滚残废。
|
|
234
|
+
与读端点对称:限伺服目录内 .html;锁内 DOC_ID/文件名归属校验防互染
|
|
235
|
+
(core.writeHistForFile 内部完成,不符退 409),条数按盘上 store.histCaps 收敛。 */
|
|
236
|
+
if (req.method === 'POST' && u === '/__annot/hist') {
|
|
237
|
+
if (!sameOriginGuard(req, res)) return;
|
|
238
|
+
readJsonBody(req, res, 20 * 1024 * 1024, function (o) {
|
|
239
|
+
try {
|
|
240
|
+
if (!o || typeof o.path !== 'string' || !o.hist || typeof o.hist !== 'object') throw new Error('bad payload');
|
|
241
|
+
var grel = o.path;
|
|
242
|
+
try { grel = decodeURIComponent(grel); } catch (e0) { /* 保持原样 */ }
|
|
243
|
+
var gtarget = path.resolve(root, '.' + grel);
|
|
244
|
+
if (gtarget !== root && gtarget.indexOf(root + path.sep) !== 0) throw new Error('路径越界');
|
|
245
|
+
if (!/\.html?$/i.test(gtarget)) throw new Error('只允许写 .html 的档案');
|
|
246
|
+
if (!fs.existsSync(gtarget)) throw new Error('目标文件不在伺服目录中');
|
|
247
|
+
var gr = core.writeHistForFile(gtarget, o.hist);
|
|
248
|
+
if (!gr.ok) {
|
|
249
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
250
|
+
res.end(JSON.stringify({ error: gr.error }));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
res.writeHead(200); res.end('hist-saved');
|
|
254
|
+
} catch (e) {
|
|
255
|
+
res.writeHead(400); res.end(String(e.message || e));
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* 静态伺服(限制在 root 内) */
|
|
262
|
+
var rel = u.replace(/^\/+/, '');
|
|
263
|
+
var fp = rel ? path.resolve(root, rel) : root;
|
|
264
|
+
if (fp !== root && fp.indexOf(root + path.sep) !== 0) { res.writeHead(403); res.end('forbidden'); return; }
|
|
265
|
+
fs.stat(fp, function (err, st) {
|
|
266
|
+
if (!err && st.isDirectory()) fp = path.join(fp, 'index.html');
|
|
267
|
+
fs.readFile(fp, function (err2, data) {
|
|
268
|
+
if (err2) { res.writeHead(404); res.end('not found'); return; }
|
|
269
|
+
res.writeHead(200, { 'Content-Type': MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream' });
|
|
270
|
+
res.end(data);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
/* v1.26.1:一个坏请求/边角 bug 不再打死整个 serve(值守生命线)——记日志继续伺服。
|
|
276
|
+
代价:出异常的那条响应可能挂到浏览器超时重试,可接受;无此守卫时进程直接退出,全部自动保存失联。 */
|
|
277
|
+
if (!(opts && opts.managed)) process.on('uncaughtException', function (err) {
|
|
278
|
+
console.error('[' + new Date().toISOString() + '] serve 吞掉未捕获异常(继续伺服): ' + ((err && err.stack) || err));
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
/* v1.27.1:绑定失败必须说实话。此前 EADDRINUSE 没挂 error 监听,异常落进上面的
|
|
282
|
+
uncaughtException 兜底、嘴上打印「继续伺服」,实际服务器从未 bind 成功、事件循环
|
|
283
|
+
清空后进程静默退出——值守以为活着,自动保存全失联。现如实报错并探测占用者身份后退出。 */
|
|
284
|
+
var rejectStart;
|
|
285
|
+
server.on('error', function (e) {
|
|
286
|
+
if (opts && opts.managed) { if (rejectStart) rejectStart(e); return; }
|
|
287
|
+
var code = (e && e.code) || 'UNKNOWN';
|
|
288
|
+
if (code !== 'EADDRINUSE') {
|
|
289
|
+
console.error('❌ serve 端口监听失败(' + code + '):' + ((e && e.message) || e));
|
|
290
|
+
process.exit(1);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
console.error('❌ 端口 ' + port + ' 已被占用,serve 没有启动。');
|
|
294
|
+
probeSelf(port, function (isAnnot) {
|
|
295
|
+
if (isAnnot) {
|
|
296
|
+
console.error(' 占用者就是另一个 annotatable serve——多半是上次值守/演示没停干净的孤儿进程(HANDOFF 已知坑)。');
|
|
297
|
+
console.error(' 处置二选一:直接复用现有 http://localhost:' + port + ';');
|
|
298
|
+
console.error(' 或 netstat -ano | findstr ":' + port + '" 查 PID 后 taskkill //F //PID <pid>,再重启。');
|
|
299
|
+
} else {
|
|
300
|
+
console.error(' 处置二选一:换端口重启(--port <其他>,或 --port 0 让系统挑空闲端口);');
|
|
301
|
+
console.error(' 或 netstat -ano | findstr ":' + port + '" 查占用者 PID,确认不要就 taskkill //F //PID <pid>。');
|
|
302
|
+
}
|
|
303
|
+
process.exit(1);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
return new Promise(function (resolve, reject) {
|
|
308
|
+
rejectStart = reject;
|
|
309
|
+
server.listen(port, '127.0.0.1', function () {
|
|
310
|
+
var actual = (server.address() && server.address().port) || port; // --port 0 时以系统实配为准
|
|
311
|
+
var info = { url: 'http://localhost:' + actual, root: root, port: actual };
|
|
312
|
+
if (opts && opts.managed) info.close = function () { return new Promise(function (done) { server.close(done); }); };
|
|
313
|
+
resolve(info);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = { start: start };
|