@aflueza/dev-tools 0.0.0-stage → 0.2.1-rc.2
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 +12 -2
- package/cordis.patch.yml +3 -0
- package/lib/api-runner/database.js +216 -0
- package/lib/api-runner/service.js +121 -0
- package/lib/client.js +180001 -0
- package/lib/crypto/service.js +242 -0
- package/lib/index.js +41 -0
- package/lib/mock-data/database.js +92 -0
- package/lib/process-manager/rpc.js +277 -0
- package/lib/process-manager/service.js +708 -0
- package/lib/shared/api-runner.js +1 -0
- package/lib/shared/crypto.js +1 -0
- package/lib/shared/mock-data.js +27 -0
- package/lib/shared/process-manager.js +1 -0
- package/lib/style.css +14655 -0
- package/package.json +66 -4
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { createCipheriv, createDecipheriv, createHash } from 'node:crypto';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { mkdirSync } from 'node:fs';
|
|
6
|
+
export class CryptoError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
itemIndex;
|
|
9
|
+
constructor(code, message, itemIndex) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.itemIndex = itemIndex;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const parse = (x) => JSON.parse(x);
|
|
16
|
+
export class CryptoService {
|
|
17
|
+
db;
|
|
18
|
+
queue = Promise.resolve();
|
|
19
|
+
constructor(path) {
|
|
20
|
+
mkdirSync(dirname(resolve(path)), { recursive: true });
|
|
21
|
+
this.db = new DatabaseSync(path);
|
|
22
|
+
this.db.exec('PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS crypto_profiles (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT NOT NULL UNIQUE,is_default INTEGER NOT NULL DEFAULT 0,config_json TEXT NOT NULL,created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);');
|
|
23
|
+
}
|
|
24
|
+
close() {
|
|
25
|
+
this.db.close();
|
|
26
|
+
}
|
|
27
|
+
write(fn) {
|
|
28
|
+
const run = this.queue.then(fn);
|
|
29
|
+
this.queue = run.then(() => undefined, () => undefined);
|
|
30
|
+
return run;
|
|
31
|
+
}
|
|
32
|
+
list() {
|
|
33
|
+
return this.db
|
|
34
|
+
.prepare('SELECT * FROM crypto_profiles ORDER BY is_default DESC,updated_at DESC,id')
|
|
35
|
+
.all().map(this.row);
|
|
36
|
+
}
|
|
37
|
+
save(payload) {
|
|
38
|
+
return this.write(() => {
|
|
39
|
+
const name = payload.name.trim() || '未命名配置';
|
|
40
|
+
if (payload.id)
|
|
41
|
+
this.db
|
|
42
|
+
.prepare('UPDATE crypto_profiles SET name=?,config_json=?,updated_at=CURRENT_TIMESTAMP WHERE id=?')
|
|
43
|
+
.run(name, JSON.stringify(payload.config), payload.id);
|
|
44
|
+
else {
|
|
45
|
+
this.db
|
|
46
|
+
.prepare('INSERT INTO crypto_profiles(name,config_json) VALUES(?,?)')
|
|
47
|
+
.run(name, JSON.stringify(payload.config));
|
|
48
|
+
payload.id = Number(this.db.prepare('SELECT last_insert_rowid() id').get().id);
|
|
49
|
+
}
|
|
50
|
+
return this.list().find((x) => x.id === payload.id);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
rename(id, name) {
|
|
54
|
+
return this.write(() => {
|
|
55
|
+
this.db
|
|
56
|
+
.prepare('UPDATE crypto_profiles SET name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?')
|
|
57
|
+
.run(name.trim() || '未命名配置', id);
|
|
58
|
+
return this.list().find((x) => x.id === id);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
delete(id) {
|
|
62
|
+
return this.write(() => {
|
|
63
|
+
this.db.prepare('DELETE FROM crypto_profiles WHERE id=?').run(id);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
setDefault(id) {
|
|
67
|
+
return this.write(() => {
|
|
68
|
+
this.db.exec('UPDATE crypto_profiles SET is_default=0');
|
|
69
|
+
this.db.prepare('UPDATE crypto_profiles SET is_default=1 WHERE id=?').run(id);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
execute(request) {
|
|
73
|
+
const c = request.config;
|
|
74
|
+
const algorithm = c.algorithm;
|
|
75
|
+
const list = c.batch?.mode === 'list';
|
|
76
|
+
const items = list
|
|
77
|
+
? splitBatch(request.input, String(c.batch.separator ?? ','), String(c.batch.wrapper ?? '"'))
|
|
78
|
+
: [request.input];
|
|
79
|
+
const outputs = items.map((item, i) => {
|
|
80
|
+
try {
|
|
81
|
+
return item === '' && ['decrypt', 'decode'].includes(request.operation)
|
|
82
|
+
? ''
|
|
83
|
+
: transform(item, request.operation, c);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
if (list && ['decrypt', 'decode'].includes(request.operation))
|
|
87
|
+
return `${request.operation === 'decrypt' ? '解密失败:' : '解码失败:'}${item}`;
|
|
88
|
+
throw new CryptoError('item-failed', e instanceof Error ? e.message : String(e), i);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
output: list
|
|
93
|
+
? outputs
|
|
94
|
+
.map((x) => `${c.batch.wrapper ?? '"'}${x}${c.batch.wrapper ?? '"'}`)
|
|
95
|
+
.join(c.batch.separator ?? ',')
|
|
96
|
+
: outputs[0],
|
|
97
|
+
itemCount: outputs.length,
|
|
98
|
+
algorithm,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
row(row) {
|
|
102
|
+
return {
|
|
103
|
+
id: Number(row.id),
|
|
104
|
+
name: String(row.name),
|
|
105
|
+
isDefault: Boolean(row.is_default),
|
|
106
|
+
config: parse(row.config_json),
|
|
107
|
+
createdAt: String(row.created_at),
|
|
108
|
+
updatedAt: String(row.updated_at),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function splitBatch(input, separator, wrapper) {
|
|
113
|
+
if (!separator)
|
|
114
|
+
throw new CryptoError('invalid-batch', '分隔符不能为空');
|
|
115
|
+
const isNewline = /^(?:\r\n|\r|\n|\u0085|\u2028|\u2029)$/.test(separator);
|
|
116
|
+
const separators = isNewline
|
|
117
|
+
? ['\r\n', '\n', '\r', '\u0085', '\u2028', '\u2029']
|
|
118
|
+
: separator === ','
|
|
119
|
+
? [',', ',']
|
|
120
|
+
: separator === ';'
|
|
121
|
+
? [';', ';']
|
|
122
|
+
: [separator];
|
|
123
|
+
const matchSeparator = (position) => separators
|
|
124
|
+
.sort((a, b) => b.length - a.length)
|
|
125
|
+
.find((value) => input.startsWith(value, position));
|
|
126
|
+
if (!wrapper) {
|
|
127
|
+
const out = [];
|
|
128
|
+
let start = 0;
|
|
129
|
+
for (let i = 0; i < input.length;) {
|
|
130
|
+
const matched = matchSeparator(i);
|
|
131
|
+
if (matched) {
|
|
132
|
+
out.push(input.slice(start, i).trim());
|
|
133
|
+
i += matched.length;
|
|
134
|
+
start = i;
|
|
135
|
+
}
|
|
136
|
+
else
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
139
|
+
out.push(input.slice(start).trim());
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
const out = [];
|
|
143
|
+
let i = 0;
|
|
144
|
+
let trailingSeparator = false;
|
|
145
|
+
while (i <= input.length) {
|
|
146
|
+
while (i < input.length && /[ \t\f\v]/.test(input[i]))
|
|
147
|
+
i++;
|
|
148
|
+
if (i >= input.length) {
|
|
149
|
+
if (trailingSeparator || out.length === 0)
|
|
150
|
+
out.push('');
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
const emptySeparator = matchSeparator(i);
|
|
154
|
+
if (emptySeparator) {
|
|
155
|
+
out.push('');
|
|
156
|
+
i += emptySeparator.length;
|
|
157
|
+
trailingSeparator = true;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!input.startsWith(wrapper, i))
|
|
161
|
+
throw new CryptoError('invalid-batch', `第 ${i + 1} 个字符必须是包裹符`);
|
|
162
|
+
i += wrapper.length;
|
|
163
|
+
const start = i;
|
|
164
|
+
let end = -1;
|
|
165
|
+
while (i < input.length) {
|
|
166
|
+
if (input.startsWith(wrapper, i)) {
|
|
167
|
+
end = i;
|
|
168
|
+
i += wrapper.length;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
i++;
|
|
172
|
+
}
|
|
173
|
+
if (end < 0)
|
|
174
|
+
throw new CryptoError('invalid-batch', '包裹符未闭合');
|
|
175
|
+
out.push(input.slice(start, end).trim());
|
|
176
|
+
trailingSeparator = false;
|
|
177
|
+
while (i < input.length && /[ \t\f\v]/.test(input[i]))
|
|
178
|
+
i++;
|
|
179
|
+
if (i === input.length)
|
|
180
|
+
break;
|
|
181
|
+
const matched = matchSeparator(i);
|
|
182
|
+
if (!matched)
|
|
183
|
+
throw new CryptoError('invalid-batch', '项之间缺少分隔符');
|
|
184
|
+
i += matched.length;
|
|
185
|
+
trailingSeparator = true;
|
|
186
|
+
}
|
|
187
|
+
if (!out.length)
|
|
188
|
+
throw new CryptoError('invalid-batch', '批量输入不能为空');
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
function escapeRegExp(value) {
|
|
192
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
193
|
+
}
|
|
194
|
+
function bytes(value, encoding) {
|
|
195
|
+
if (encoding === 'hex') {
|
|
196
|
+
if (!/^(?:[0-9a-f]{2})*$/i.test(value))
|
|
197
|
+
throw new Error('Hex 内容无效');
|
|
198
|
+
return Buffer.from(value, 'hex');
|
|
199
|
+
}
|
|
200
|
+
if (encoding === 'base64')
|
|
201
|
+
return Buffer.from(value, 'base64');
|
|
202
|
+
return Buffer.from(value, 'utf8');
|
|
203
|
+
}
|
|
204
|
+
function transform(input, operation, c) {
|
|
205
|
+
const inEnc = c.inputEncoding || 'utf8', outEnc = c.outputEncoding || 'base64';
|
|
206
|
+
if (c.algorithm === 'base64') {
|
|
207
|
+
if (operation === 'encode')
|
|
208
|
+
return Buffer.from(input, inEnc === 'hex' ? 'hex' : inEnc === 'base64' ? 'base64' : 'utf8').toString(outEnc === 'hex' ? 'hex' : 'base64');
|
|
209
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(input))
|
|
210
|
+
throw new Error('Base64 内容无效');
|
|
211
|
+
const decoded = Buffer.from(input, 'base64');
|
|
212
|
+
return outEnc === 'hex'
|
|
213
|
+
? decoded.toString('hex')
|
|
214
|
+
: outEnc === 'base64'
|
|
215
|
+
? decoded.toString('base64')
|
|
216
|
+
: decoded.toString('utf8');
|
|
217
|
+
}
|
|
218
|
+
if (c.algorithm === 'sm3') {
|
|
219
|
+
const b = bytes(input, inEnc);
|
|
220
|
+
const h = createHash('sm3').update(b).digest();
|
|
221
|
+
return outEnc === 'hex' ? h.toString('hex') : h.toString('base64');
|
|
222
|
+
}
|
|
223
|
+
const key = bytes(String(c.key || ''), c.keyEncoding || 'utf8');
|
|
224
|
+
const mode = c.mode || 'cbc';
|
|
225
|
+
const cipherName = c.algorithm === 'aes' ? `aes-${key.length * 8}-${mode}` : `sm4-${mode}`;
|
|
226
|
+
if (![16, 24, 32].includes(key.length) || (c.algorithm === 'sm4' && key.length !== 16))
|
|
227
|
+
throw new Error('密钥长度无效');
|
|
228
|
+
const iv = mode === 'ecb' ? null : bytes(String(c.iv || ''), c.ivEncoding || 'utf8');
|
|
229
|
+
if (iv !== null && iv.length !== 16)
|
|
230
|
+
throw new Error('IV 必须为 16 字节');
|
|
231
|
+
const options = c.padding === 'nopadding' ? { autoPadding: false } : undefined;
|
|
232
|
+
const data = bytes(input, inEnc);
|
|
233
|
+
const x = operation === 'encrypt'
|
|
234
|
+
? createCipheriv(cipherName, key, iv, options)
|
|
235
|
+
: createDecipheriv(cipherName, key, iv, options);
|
|
236
|
+
const result = Buffer.concat([x.update(data), x.final()]);
|
|
237
|
+
return outEnc === 'hex'
|
|
238
|
+
? result.toString('hex')
|
|
239
|
+
: outEnc === 'utf8'
|
|
240
|
+
? result.toString('utf8')
|
|
241
|
+
: result.toString('base64');
|
|
242
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
2
|
+
import { handleRpcRequest, RPC_CHANNEL } from './process-manager/rpc.js';
|
|
3
|
+
import { ApiRunnerService } from './api-runner/service.js';
|
|
4
|
+
import { MockDataDatabase } from './mock-data/database.js';
|
|
5
|
+
import { CryptoService } from './crypto/service.js';
|
|
6
|
+
import z from '@deepseek-ai/schemastery';
|
|
7
|
+
export const name = 'dev-tools';
|
|
8
|
+
export const inject = ['connection', 'webServer'];
|
|
9
|
+
export const Config = z
|
|
10
|
+
.object({
|
|
11
|
+
apiRunner: z
|
|
12
|
+
.object({
|
|
13
|
+
requestTimeoutMs: z.number().default(30000),
|
|
14
|
+
maxResponseBytes: z.number().default(4194304),
|
|
15
|
+
historyLimit: z.number().default(100),
|
|
16
|
+
})
|
|
17
|
+
.default({}),
|
|
18
|
+
})
|
|
19
|
+
.default({});
|
|
20
|
+
export function apply(ctx, config) {
|
|
21
|
+
ctx.inject(['connection', 'webServer'], (rpcCtx) => {
|
|
22
|
+
const webServer = rpcCtx.webServer;
|
|
23
|
+
const options = config.apiRunner ?? {};
|
|
24
|
+
const api = new ApiRunnerService(dshHomePath('plugins', 'dev-tools', 'dev-tools-api.sqlite'), options.requestTimeoutMs ?? 30000, options.maxResponseBytes ?? 4194304, options.historyLimit ?? 100);
|
|
25
|
+
const mock = new MockDataDatabase(dshHomePath('plugins', 'dev-tools', 'dev-tools-mock.sqlite'));
|
|
26
|
+
const crypto = new CryptoService(dshHomePath('plugins', 'dev-tools', 'dev-tools-crypto.sqlite'));
|
|
27
|
+
rpcCtx.effect(() => {
|
|
28
|
+
const unregister = webServer.register({
|
|
29
|
+
kind: 'prefix',
|
|
30
|
+
path: RPC_CHANNEL,
|
|
31
|
+
handler: (request, response) => handleRpcRequest(rpcCtx.connection, request, response, api, mock, crypto),
|
|
32
|
+
});
|
|
33
|
+
return () => {
|
|
34
|
+
unregister();
|
|
35
|
+
api.close();
|
|
36
|
+
mock.close();
|
|
37
|
+
crypto.close();
|
|
38
|
+
};
|
|
39
|
+
}, 'dev-tools: RPC route');
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
export class MockDataDatabase {
|
|
5
|
+
db;
|
|
6
|
+
queue = Promise.resolve();
|
|
7
|
+
constructor(path) {
|
|
8
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
9
|
+
this.db = new DatabaseSync(path);
|
|
10
|
+
this.db.exec('PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS mock_scenarios (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT NOT NULL,fields_json TEXT NOT NULL,created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,last_used_at TEXT);');
|
|
11
|
+
}
|
|
12
|
+
close() {
|
|
13
|
+
this.db.close();
|
|
14
|
+
}
|
|
15
|
+
run(task) {
|
|
16
|
+
const result = this.queue.then(task);
|
|
17
|
+
this.queue = result.then(() => undefined, () => undefined);
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
map(row) {
|
|
21
|
+
return {
|
|
22
|
+
id: Number(row.id),
|
|
23
|
+
name: String(row.name),
|
|
24
|
+
fields: JSON.parse(String(row.fields_json)),
|
|
25
|
+
createdAt: String(row.created_at),
|
|
26
|
+
updatedAt: String(row.updated_at),
|
|
27
|
+
lastUsedAt: row.last_used_at ? String(row.last_used_at) : null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
list() {
|
|
31
|
+
return this.db
|
|
32
|
+
.prepare('SELECT * FROM mock_scenarios ORDER BY COALESCE(last_used_at, updated_at) DESC, id DESC')
|
|
33
|
+
.all().map((x) => this.map(x));
|
|
34
|
+
}
|
|
35
|
+
save(payload) {
|
|
36
|
+
return this.run(() => {
|
|
37
|
+
const name = payload.name.trim();
|
|
38
|
+
if (!name)
|
|
39
|
+
throw new Error('场景名称不能为空');
|
|
40
|
+
const fields = JSON.stringify(payload.fields);
|
|
41
|
+
if (payload.id)
|
|
42
|
+
this.db
|
|
43
|
+
.prepare('UPDATE mock_scenarios SET name=?,fields_json=?,updated_at=CURRENT_TIMESTAMP WHERE id=?')
|
|
44
|
+
.run(name, fields, payload.id);
|
|
45
|
+
else {
|
|
46
|
+
this.db
|
|
47
|
+
.prepare('INSERT INTO mock_scenarios(name,fields_json) VALUES(?,?)')
|
|
48
|
+
.run(name, fields);
|
|
49
|
+
payload.id = Number(this.db.prepare('SELECT last_insert_rowid() id').get().id);
|
|
50
|
+
}
|
|
51
|
+
const row = this.db
|
|
52
|
+
.prepare('SELECT * FROM mock_scenarios WHERE id=?')
|
|
53
|
+
.get(payload.id);
|
|
54
|
+
return this.map(row);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
rename(id, value) {
|
|
58
|
+
return this.run(() => {
|
|
59
|
+
const name = value.trim();
|
|
60
|
+
if (!Number.isInteger(id) || id <= 0)
|
|
61
|
+
throw new Error('场景 id 无效');
|
|
62
|
+
if (!name)
|
|
63
|
+
throw new Error('场景名称不能为空');
|
|
64
|
+
const result = this.db
|
|
65
|
+
.prepare('UPDATE mock_scenarios SET name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?')
|
|
66
|
+
.run(name, id);
|
|
67
|
+
if (Number(result.changes) === 0)
|
|
68
|
+
throw new Error('场景不存在');
|
|
69
|
+
return this.map(this.db.prepare('SELECT * FROM mock_scenarios WHERE id=?').get(id));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
delete(id) {
|
|
73
|
+
return this.run(() => {
|
|
74
|
+
if (!Number.isInteger(id) || id <= 0)
|
|
75
|
+
throw new Error('场景 id 无效');
|
|
76
|
+
const result = this.db.prepare('DELETE FROM mock_scenarios WHERE id=?').run(id);
|
|
77
|
+
if (Number(result.changes) === 0)
|
|
78
|
+
throw new Error('场景不存在');
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
use(id) {
|
|
82
|
+
return this.run(() => {
|
|
83
|
+
this.db
|
|
84
|
+
.prepare('UPDATE mock_scenarios SET last_used_at=CURRENT_TIMESTAMP WHERE id=?')
|
|
85
|
+
.run(id);
|
|
86
|
+
const row = this.db.prepare('SELECT * FROM mock_scenarios WHERE id=?').get(id);
|
|
87
|
+
if (!row)
|
|
88
|
+
throw new Error('场景不存在');
|
|
89
|
+
return this.map(row);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { DEV_TOOLS_RPC_CHANNEL, } from '../shared/process-manager.js';
|
|
2
|
+
import { ProcessManagerError, findFileLockers, getProcessDetail, listProcesses, terminateProcess, } from './service.js';
|
|
3
|
+
import { ApiRunnerError } from '../api-runner/service.js';
|
|
4
|
+
export const RPC_CHANNEL = DEV_TOOLS_RPC_CHANNEL;
|
|
5
|
+
export async function handleRpcRequest(connection, request, response, api, mock, crypto) {
|
|
6
|
+
const rejection = connection.requestRejection(request);
|
|
7
|
+
if (rejection !== undefined) {
|
|
8
|
+
response.writeHead(rejection);
|
|
9
|
+
response.end(rejection === 401 ? 'unauthorized' : 'forbidden');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const endpoint = endpointFromRequest(request);
|
|
13
|
+
if (request.method !== 'POST' || endpoint === null) {
|
|
14
|
+
response.writeHead(404);
|
|
15
|
+
response.end('not found');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const mediaType = request.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase();
|
|
19
|
+
if (mediaType !== 'application/json') {
|
|
20
|
+
response.writeHead(415);
|
|
21
|
+
response.end('content type must be application/json');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let message;
|
|
25
|
+
try {
|
|
26
|
+
message = parseClientRequest(await readJsonBody(request));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
response.writeHead(400);
|
|
30
|
+
response.end('invalid rpc request');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (message.method !== endpoint) {
|
|
34
|
+
writeRpcResult(response, message.rpcId, fail('invalid-payload', `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (endpoint.startsWith('api/')) {
|
|
38
|
+
if (!api) {
|
|
39
|
+
writeRpcResult(response, message.rpcId, fail('unavailable', 'API runner is unavailable'));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
writeRpcResult(response, message.rpcId, ok(await dispatchApi(api, endpoint, message.payload)));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const e = error instanceof ApiRunnerError
|
|
47
|
+
? error
|
|
48
|
+
: new ApiRunnerError('internal', error instanceof Error ? error.message : String(error));
|
|
49
|
+
writeRpcResult(response, message.rpcId, fail(e.code, e.message));
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (endpoint.startsWith('mock/')) {
|
|
54
|
+
if (!mock) {
|
|
55
|
+
writeRpcResult(response, message.rpcId, fail('unavailable', 'Mock Data is unavailable'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
writeRpcResult(response, message.rpcId, ok(await dispatchMock(mock, endpoint, message.payload)));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
writeRpcResult(response, message.rpcId, fail('mock-error', error instanceof Error ? error.message : String(error)));
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (endpoint.startsWith('crypto/')) {
|
|
67
|
+
if (!crypto) {
|
|
68
|
+
writeRpcResult(response, message.rpcId, fail('unavailable', 'Crypto is unavailable'));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
writeRpcResult(response, message.rpcId, ok(await dispatchCrypto(crypto, endpoint, message.payload)));
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const e = error;
|
|
76
|
+
writeRpcResult(response, message.rpcId, fail(e.code ?? 'crypto-error', e.message ?? String(error), e.itemIndex === undefined ? {} : { itemIndex: e.itemIndex }));
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
writeRpcResult(response, message.rpcId, await dispatch(endpoint, message.payload));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
writeRpcResult(response, message.rpcId, fail('internal', error instanceof Error ? error.message : String(error)));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function dispatchCrypto(service, endpoint, payload) {
|
|
88
|
+
switch (endpoint) {
|
|
89
|
+
case 'crypto/profiles/list':
|
|
90
|
+
return service.list();
|
|
91
|
+
case 'crypto/profiles/save':
|
|
92
|
+
return service.save(payload);
|
|
93
|
+
case 'crypto/profiles/rename':
|
|
94
|
+
return service.rename(Number(payload?.id), String(payload?.name ?? ''));
|
|
95
|
+
case 'crypto/profiles/delete':
|
|
96
|
+
return service.delete(Number(payload?.id));
|
|
97
|
+
case 'crypto/profiles/set-default':
|
|
98
|
+
return service.setDefault(Number(payload?.id));
|
|
99
|
+
case 'crypto/execute':
|
|
100
|
+
return service.execute(payload);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function dispatchMock(db, endpoint, payload) {
|
|
104
|
+
switch (endpoint) {
|
|
105
|
+
case 'mock/scenarios/list':
|
|
106
|
+
return db.list();
|
|
107
|
+
case 'mock/scenarios/save':
|
|
108
|
+
return db.save(payload);
|
|
109
|
+
case 'mock/scenarios/use':
|
|
110
|
+
return db.use(Number(payload?.id));
|
|
111
|
+
case 'mock/scenarios/rename':
|
|
112
|
+
return db.rename(Number(payload?.id), String(payload?.name ?? ''));
|
|
113
|
+
case 'mock/scenarios/delete':
|
|
114
|
+
return db.delete(Number(payload?.id));
|
|
115
|
+
default:
|
|
116
|
+
throw new Error(`unknown endpoint "${endpoint}"`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function dispatchApi(api, endpoint, payload) {
|
|
120
|
+
switch (endpoint) {
|
|
121
|
+
case 'api/send':
|
|
122
|
+
return api.send(payload);
|
|
123
|
+
case 'api/collections/list':
|
|
124
|
+
return api.database.listCollections();
|
|
125
|
+
case 'api/collections/create':
|
|
126
|
+
return api.database.createCollection(String(payload?.name ?? ''));
|
|
127
|
+
case 'api/collections/ensure':
|
|
128
|
+
return api.database.ensureCollection(String(payload?.name ?? ''));
|
|
129
|
+
case 'api/collections/rename':
|
|
130
|
+
return api.database.renameCollection(Number(payload?.id), String(payload?.name ?? ''));
|
|
131
|
+
case 'api/collections/delete':
|
|
132
|
+
return api.database.deleteCollection(Number(payload?.id));
|
|
133
|
+
case 'api/collections/reorder':
|
|
134
|
+
return api.database.reorderCollections((payload?.ids ?? []).map(Number));
|
|
135
|
+
case 'api/requests/list':
|
|
136
|
+
return api.database.listRequests();
|
|
137
|
+
case 'api/requests/save':
|
|
138
|
+
return api.database.saveRequest(payload);
|
|
139
|
+
case 'api/requests/rename':
|
|
140
|
+
return api.database.renameRequest(Number(payload?.id), String(payload?.name ?? ''));
|
|
141
|
+
case 'api/requests/delete':
|
|
142
|
+
return api.database.deleteRequest(Number(payload?.id));
|
|
143
|
+
case 'api/requests/reorder':
|
|
144
|
+
return api.database.reorderRequests(Number(payload?.collectionId), (payload?.ids ?? []).map(Number));
|
|
145
|
+
case 'api/requests/move':
|
|
146
|
+
return api.database.moveRequest(Number(payload?.id), Number(payload?.collectionId));
|
|
147
|
+
case 'api/history/list':
|
|
148
|
+
return api.database.listHistory(Number(payload?.limit ?? 100));
|
|
149
|
+
case 'api/history/delete':
|
|
150
|
+
return api.database.deleteHistory(Number(payload?.id));
|
|
151
|
+
case 'api/history/clear':
|
|
152
|
+
return api.database.clearHistory();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function parseClientRequest(value) {
|
|
156
|
+
if (typeof value !== 'object' || value === null)
|
|
157
|
+
throw new TypeError('request must be an object');
|
|
158
|
+
const message = value;
|
|
159
|
+
if (message.type !== 'client-request' ||
|
|
160
|
+
typeof message.rpcId !== 'string' ||
|
|
161
|
+
typeof message.method !== 'string' ||
|
|
162
|
+
!('payload' in message)) {
|
|
163
|
+
throw new TypeError('invalid client request envelope');
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
type: 'client-request',
|
|
167
|
+
rpcId: message.rpcId,
|
|
168
|
+
method: message.method,
|
|
169
|
+
payload: message.payload,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function endpointFromRequest(request) {
|
|
173
|
+
const pathname = new URL(request.url ?? '/', 'http://x').pathname;
|
|
174
|
+
const prefix = `${RPC_CHANNEL}/`;
|
|
175
|
+
if (!pathname.startsWith(prefix))
|
|
176
|
+
return null;
|
|
177
|
+
const endpoint = pathname.slice(prefix.length);
|
|
178
|
+
return endpoint || null;
|
|
179
|
+
}
|
|
180
|
+
async function readJsonBody(request) {
|
|
181
|
+
const chunks = [];
|
|
182
|
+
let size = 0;
|
|
183
|
+
for await (const chunk of request) {
|
|
184
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
185
|
+
size += buffer.byteLength;
|
|
186
|
+
if (size > 1024 * 1024)
|
|
187
|
+
throw new RangeError('request body exceeds 1 MiB');
|
|
188
|
+
chunks.push(buffer);
|
|
189
|
+
}
|
|
190
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
191
|
+
}
|
|
192
|
+
function writeRpcResult(response, rpcId, result) {
|
|
193
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
194
|
+
response.end(JSON.stringify({ type: 'server-response', rpcId, result }));
|
|
195
|
+
}
|
|
196
|
+
function ok(value) {
|
|
197
|
+
return { ok: true, value };
|
|
198
|
+
}
|
|
199
|
+
function fail(code, message, details = {}) {
|
|
200
|
+
return { ok: false, error: { code, message, details } };
|
|
201
|
+
}
|
|
202
|
+
export async function dispatch(endpoint, payload) {
|
|
203
|
+
try {
|
|
204
|
+
switch (endpoint) {
|
|
205
|
+
case 'process/list': {
|
|
206
|
+
const input = parseListPayload(payload);
|
|
207
|
+
return ok(await listProcesses(input.query));
|
|
208
|
+
}
|
|
209
|
+
case 'process/detail': {
|
|
210
|
+
const input = parseDetailPayload(payload);
|
|
211
|
+
return ok(await getProcessDetail(input.pid));
|
|
212
|
+
}
|
|
213
|
+
case 'process/terminate': {
|
|
214
|
+
const input = parseTerminatePayload(payload);
|
|
215
|
+
return ok(terminateProcess(input.pid, input.force));
|
|
216
|
+
}
|
|
217
|
+
case 'file/lockers': {
|
|
218
|
+
const input = parseFileLockPayload(payload);
|
|
219
|
+
return ok(await findFileLockers(input.path));
|
|
220
|
+
}
|
|
221
|
+
default:
|
|
222
|
+
return fail('unknown-endpoint', `unknown endpoint "${endpoint}"`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof ProcessManagerError) {
|
|
227
|
+
return fail(error.code, error.message, error.details);
|
|
228
|
+
}
|
|
229
|
+
return fail('internal', error instanceof Error ? error.message : String(error));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function parseListPayload(payload) {
|
|
233
|
+
if (typeof payload !== 'object' || payload === null || !('query' in payload)) {
|
|
234
|
+
throw new ProcessManagerError('invalid-payload', 'process/list requires a query string');
|
|
235
|
+
}
|
|
236
|
+
const query = payload.query;
|
|
237
|
+
if (typeof query !== 'string') {
|
|
238
|
+
throw new ProcessManagerError('invalid-payload', 'process/list requires a query string');
|
|
239
|
+
}
|
|
240
|
+
return { query };
|
|
241
|
+
}
|
|
242
|
+
function parseDetailPayload(payload) {
|
|
243
|
+
if (typeof payload !== 'object' || payload === null || !('pid' in payload)) {
|
|
244
|
+
throw new ProcessManagerError('invalid-payload', 'process/detail requires a pid');
|
|
245
|
+
}
|
|
246
|
+
const pid = payload.pid;
|
|
247
|
+
if (!isPid(pid)) {
|
|
248
|
+
throw new ProcessManagerError('invalid-payload', 'process/detail requires a positive pid');
|
|
249
|
+
}
|
|
250
|
+
return { pid };
|
|
251
|
+
}
|
|
252
|
+
function parseTerminatePayload(payload) {
|
|
253
|
+
if (typeof payload !== 'object' || payload === null || !('pid' in payload)) {
|
|
254
|
+
throw new ProcessManagerError('invalid-payload', 'process/terminate requires a pid');
|
|
255
|
+
}
|
|
256
|
+
const value = payload;
|
|
257
|
+
if (!isPid(value.pid)) {
|
|
258
|
+
throw new ProcessManagerError('invalid-payload', 'process/terminate requires a positive pid');
|
|
259
|
+
}
|
|
260
|
+
if (value.force !== undefined && typeof value.force !== 'boolean') {
|
|
261
|
+
throw new ProcessManagerError('invalid-payload', 'process/terminate force must be boolean');
|
|
262
|
+
}
|
|
263
|
+
return { pid: value.pid, force: value.force };
|
|
264
|
+
}
|
|
265
|
+
function parseFileLockPayload(payload) {
|
|
266
|
+
if (typeof payload !== 'object' || payload === null || !('path' in payload)) {
|
|
267
|
+
throw new ProcessManagerError('invalid-payload', 'file/lockers requires a path');
|
|
268
|
+
}
|
|
269
|
+
const path = payload.path;
|
|
270
|
+
if (typeof path !== 'string' || !path.trim()) {
|
|
271
|
+
throw new ProcessManagerError('invalid-payload', 'file/lockers requires a non-empty path');
|
|
272
|
+
}
|
|
273
|
+
return { path };
|
|
274
|
+
}
|
|
275
|
+
function isPid(value) {
|
|
276
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
|
277
|
+
}
|