dsh-session-ops 0.1.4 → 0.2.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 +16 -11
- package/README.zh.md +28 -40
- package/cordis.patch.yml +1 -1
- package/docs/DETAIL.md +9 -7
- package/docs/DETAIL.zh.md +20 -14
- package/lib/admin.js +723 -774
- package/lib/client.js +1767 -2065
- package/lib/engine.js +181 -182
- package/lib/index.js +125 -166
- package/lib/memory.js +137 -132
- package/lib/move.js +630 -644
- package/lib/sessionlog.js +2 -18
- package/lib/store/frames.js +183 -170
- package/lib/store/host.js +41 -42
- package/lib/store/http.js +64 -59
- package/lib/store/index.js +5 -6
- package/lib/store/paths.js +79 -76
- package/lib/store/registry.js +100 -89
- package/lib/store/util.js +19 -0
- package/lib/types/admin.d.ts +187 -0
- package/lib/types/engine.d.ts +136 -0
- package/lib/types/index.d.ts +281 -0
- package/lib/types/memory.d.ts +106 -0
- package/lib/types/move.d.ts +272 -0
- package/lib/types/sessionlog.d.ts +14 -0
- package/lib/types/store/frames.d.ts +128 -0
- package/lib/types/store/host.d.ts +88 -0
- package/lib/types/store/http.d.ts +105 -0
- package/lib/types/store/index.d.ts +30 -0
- package/lib/types/store/paths.d.ts +100 -0
- package/lib/types/store/registry.d.ts +206 -0
- package/lib/types/store/util.d.ts +16 -0
- package/package.json +13 -3
package/lib/store/http.js
CHANGED
|
@@ -22,10 +22,9 @@
|
|
|
22
22
|
*
|
|
23
23
|
* @module dsh-session-ops/store/http
|
|
24
24
|
*/
|
|
25
|
-
|
|
25
|
+
import { messageOf } from './util.js';
|
|
26
26
|
/** 请求体上限:1 MiB。 */
|
|
27
|
-
const MAX_BODY_BYTES = 1024 * 1024
|
|
28
|
-
|
|
27
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
29
28
|
/**
|
|
30
29
|
* 写一个 JSON 响应。
|
|
31
30
|
* @param res - Node HTTP 响应。
|
|
@@ -37,19 +36,19 @@ const MAX_BODY_BYTES = 1024 * 1024
|
|
|
37
36
|
* @returns 无。
|
|
38
37
|
*/
|
|
39
38
|
export function writeJson(res, status, body, options = {}) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
const text = JSON.stringify(body);
|
|
40
|
+
if (options.contentLength === true) {
|
|
41
|
+
res.writeHead(status, {
|
|
42
|
+
'content-type': 'application/json; charset=utf-8',
|
|
43
|
+
'cache-control': 'no-store',
|
|
44
|
+
'content-length': Buffer.byteLength(text),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
49
|
+
}
|
|
50
|
+
res.end(text);
|
|
51
51
|
}
|
|
52
|
-
|
|
53
52
|
/**
|
|
54
53
|
* 读一个 JSON 请求体(上限 1 MiB)。
|
|
55
54
|
*
|
|
@@ -64,24 +63,28 @@ export function writeJson(res, status, body, options = {}) {
|
|
|
64
63
|
* @returns 解析出来的载荷对象。
|
|
65
64
|
*/
|
|
66
65
|
export async function readJsonBody(req, options = {}) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
66
|
+
const requireObject = options.requireObject === true;
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
for await (const chunk of req) {
|
|
70
|
+
size += chunk.length;
|
|
71
|
+
if (size > MAX_BODY_BYTES)
|
|
72
|
+
throw new Error('request body too large');
|
|
73
|
+
chunks.push(chunk);
|
|
74
|
+
}
|
|
75
|
+
if (chunks.length === 0)
|
|
76
|
+
return {};
|
|
77
|
+
const buffer = Buffer.concat(chunks).toString('utf8');
|
|
78
|
+
if (!requireObject)
|
|
79
|
+
return JSON.parse(buffer);
|
|
80
|
+
const text = buffer.trim();
|
|
81
|
+
if (text === '')
|
|
82
|
+
return {};
|
|
83
|
+
const parsed = JSON.parse(text);
|
|
84
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
85
|
+
throw new Error('request body must be a JSON object');
|
|
86
|
+
return parsed;
|
|
83
87
|
}
|
|
84
|
-
|
|
85
88
|
/**
|
|
86
89
|
* 造一个 `kind: 'prefix'` 路由处理器(`ctx.webServer.register({kind:'prefix', path, handler})` 的 `handler`)。
|
|
87
90
|
*
|
|
@@ -98,31 +101,33 @@ export async function readJsonBody(req, options = {}) {
|
|
|
98
101
|
* @returns `async (req, res) => void`。
|
|
99
102
|
*/
|
|
100
103
|
export function createApiHandler(options) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
104
|
+
const { prefix, methods, trustRequest, log, readBody = readJsonBody, contentLength = false } = options;
|
|
105
|
+
const reply = (res, status, body) => writeJson(res, status, body, { contentLength });
|
|
106
|
+
return async (req, res) => {
|
|
107
|
+
if (typeof trustRequest !== 'function' || !trustRequest(req)) {
|
|
108
|
+
reply(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal');
|
|
112
|
+
const method = url.pathname.startsWith(`${prefix}/`) ? url.pathname.slice(prefix.length + 1) : undefined;
|
|
113
|
+
if (method === undefined || method.includes('/')) {
|
|
114
|
+
reply(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method' } });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const handler = methods[method];
|
|
118
|
+
if (typeof handler !== 'function') {
|
|
119
|
+
reply(res, 404, { ok: false, error: { code: 'not-found', message: `unknown method ${method}` } });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const payload = req.method === 'GET' ? Object.fromEntries(url.searchParams) : await readBody(req);
|
|
124
|
+
const value = await handler(payload);
|
|
125
|
+
reply(res, 200, { ok: true, value });
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (typeof log === 'function')
|
|
129
|
+
log('warn', `${method} failed: ${messageOf(error)}`);
|
|
130
|
+
reply(res, 400, { ok: false, error: { code: 'bad-request', message: messageOf(error) } });
|
|
131
|
+
}
|
|
132
|
+
};
|
|
128
133
|
}
|
package/lib/store/index.js
CHANGED
|
@@ -23,9 +23,8 @@
|
|
|
23
23
|
*
|
|
24
24
|
* @module dsh-session-ops/store
|
|
25
25
|
*/
|
|
26
|
-
|
|
27
|
-
export * from './
|
|
28
|
-
export * from './
|
|
29
|
-
export * from './
|
|
30
|
-
export * from './
|
|
31
|
-
export * from './http.js'
|
|
26
|
+
export * from './paths.js';
|
|
27
|
+
export * from './frames.js';
|
|
28
|
+
export * from './registry.js';
|
|
29
|
+
export * from './host.js';
|
|
30
|
+
export * from './http.js';
|
package/lib/store/paths.js
CHANGED
|
@@ -16,58 +16,63 @@
|
|
|
16
16
|
*
|
|
17
17
|
* @module dsh-session-ops/store/paths
|
|
18
18
|
*/
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
19
|
+
import { readdirSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { messageOf } from './util.js';
|
|
23
22
|
/** generation 规范名:`session.jsonl[.zstd]` 或 `session.vN.jsonl[.zstd]`。 */
|
|
24
|
-
const GENERATION_NAME = /^session(?:\.v(\d+))?\.jsonl(\.zstd)
|
|
25
|
-
|
|
23
|
+
const GENERATION_NAME = /^session(?:\.v(\d+))?\.jsonl(\.zstd)?$/;
|
|
26
24
|
/**
|
|
27
25
|
* 把任意字符串编码成一个安全路径段(与会话 id 共用同一套转义)。
|
|
28
26
|
* @param raw - 原始字符串。
|
|
29
27
|
* @returns 单个文件系统安全路径段。
|
|
30
28
|
*/
|
|
31
29
|
export function encodeSegment(raw) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
if (raw.length === 0)
|
|
31
|
+
throw new Error('cannot encode an empty path segment');
|
|
32
|
+
if (raw === '.')
|
|
33
|
+
return '~002E';
|
|
34
|
+
if (raw === '..')
|
|
35
|
+
return '~002E~002E';
|
|
36
|
+
let out = '';
|
|
37
|
+
for (let i = 0; i < raw.length; i++) {
|
|
38
|
+
const code = raw.charCodeAt(i);
|
|
39
|
+
const ch = String.fromCharCode(code);
|
|
40
|
+
if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch))
|
|
41
|
+
out += ch;
|
|
42
|
+
else
|
|
43
|
+
out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
43
46
|
}
|
|
44
|
-
|
|
45
47
|
/**
|
|
46
48
|
* 项目目录键:分隔符折叠成 `-`,不安全码位 `~XXXX`,截断到 251 字符。
|
|
47
49
|
* @param cwd - 会话的项目目录。
|
|
48
50
|
* @returns 单个文件系统安全的项目目录名。
|
|
49
51
|
*/
|
|
50
52
|
export function projectKey(cwd) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
53
|
+
if (cwd.length === 0)
|
|
54
|
+
throw new Error('cannot encode an empty project path');
|
|
55
|
+
let readable = '';
|
|
56
|
+
let separatorRun = false;
|
|
57
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
58
|
+
const code = cwd.charCodeAt(i);
|
|
59
|
+
const ch = String.fromCharCode(code);
|
|
60
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
61
|
+
if (!separatorRun)
|
|
62
|
+
readable += '-';
|
|
63
|
+
separatorRun = true;
|
|
64
|
+
}
|
|
65
|
+
else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
66
|
+
readable += ch;
|
|
67
|
+
separatorRun = false;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
readable += '~' + code.toString(16).toUpperCase().padStart(4, '0');
|
|
71
|
+
separatorRun = false;
|
|
72
|
+
}
|
|
66
73
|
}
|
|
67
|
-
|
|
68
|
-
return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`
|
|
74
|
+
return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`;
|
|
69
75
|
}
|
|
70
|
-
|
|
71
76
|
/**
|
|
72
77
|
* 把 `sessions/` 下的项目目录键还原成路径。
|
|
73
78
|
*
|
|
@@ -78,15 +83,16 @@ export function projectKey(cwd) {
|
|
|
78
83
|
* @returns 反解出的路径。
|
|
79
84
|
*/
|
|
80
85
|
export function decodeProjectKey(key) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
let s = key;
|
|
87
|
+
if (s.startsWith('--'))
|
|
88
|
+
s = s.slice(2);
|
|
89
|
+
if (s.endsWith('--'))
|
|
90
|
+
s = s.slice(0, -2);
|
|
91
|
+
s = s.replace(/~([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
92
|
+
s = s.replace(/-/g, '\\');
|
|
93
|
+
// 盘符的冒号在目录键里没有单独转义(`C:\a` → `--C-a--`),补回来。
|
|
94
|
+
return /^[A-Za-z]\\/.test(s) ? `${s[0]}:${s.slice(1)}` : s;
|
|
88
95
|
}
|
|
89
|
-
|
|
90
96
|
/**
|
|
91
97
|
* **宽松**的会话日志文件名判定:`.jsonl.zstd` 或 `.jsonl` 结尾就算。
|
|
92
98
|
*
|
|
@@ -97,20 +103,19 @@ export function decodeProjectKey(key) {
|
|
|
97
103
|
* @returns 是否像一份会话日志。
|
|
98
104
|
*/
|
|
99
105
|
export function isSessionLogFile(name) {
|
|
100
|
-
|
|
106
|
+
return name.endsWith('.jsonl.zstd') || name.endsWith('.jsonl');
|
|
101
107
|
}
|
|
102
|
-
|
|
103
108
|
/**
|
|
104
109
|
* 目录里是不是规范 generation 名。
|
|
105
110
|
* @param name - 目录项名。
|
|
106
111
|
* @returns 版本号(v0 为 0)与是否压缩;不是规范名时 `undefined`。
|
|
107
112
|
*/
|
|
108
113
|
export function parseGenerationName(name) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
const match = GENERATION_NAME.exec(name);
|
|
115
|
+
if (match === null)
|
|
116
|
+
return undefined;
|
|
117
|
+
return { version: match[1] === undefined ? 0 : Number(match[1]), compressed: match[2] === '.zstd' };
|
|
112
118
|
}
|
|
113
|
-
|
|
114
119
|
/**
|
|
115
120
|
* 选出会话目录里版本最高的规范 generation 文件。
|
|
116
121
|
*
|
|
@@ -121,43 +126,41 @@ export function parseGenerationName(name) {
|
|
|
121
126
|
* (错误文案被两个插件的用户可见报告引用,改动前先看测试)。
|
|
122
127
|
*/
|
|
123
128
|
export function findGeneration(dir) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return { error: `cannot list session directory ${dir}: ${messageOf(error)}` };
|
|
135
|
+
}
|
|
136
|
+
const generations = [];
|
|
137
|
+
const others = [];
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const parsed = parseGenerationName(entry.name);
|
|
140
|
+
if (parsed === undefined) {
|
|
141
|
+
others.push(entry.name);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
generations.push({ name: entry.name, version: parsed.version, compressed: parsed.compressed });
|
|
137
145
|
}
|
|
138
|
-
generations.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return { selected: generations[0], all: generations, others }
|
|
146
|
+
if (generations.length === 0)
|
|
147
|
+
return { error: `no canonical session log in ${dir}`, others };
|
|
148
|
+
generations.sort((left, right) => right.version - left.version);
|
|
149
|
+
return { selected: generations[0], all: generations, others };
|
|
143
150
|
}
|
|
144
|
-
|
|
145
151
|
/** 物理编码后缀。 */
|
|
146
152
|
export function compressionSuffix(compression) {
|
|
147
|
-
|
|
153
|
+
return compression === 'zstd' ? '.zstd' : '';
|
|
148
154
|
}
|
|
149
|
-
|
|
150
155
|
/** 一个 generation 的规范文件名(v0 不带 `vN` 段)。 */
|
|
151
156
|
export function generationLogFilename(version, compression) {
|
|
152
|
-
|
|
157
|
+
return `${version === 0 ? 'session' : `session.v${version}`}.jsonl${compressionSuffix(compression)}`;
|
|
153
158
|
}
|
|
154
|
-
|
|
155
159
|
/** 会话目录:`<root>/<projectKey>/<encodeSegment(id)>`。 */
|
|
156
160
|
export function sessionDir(root, cwd, id) {
|
|
157
|
-
|
|
161
|
+
return join(join(root, cwd === undefined ? '_no-cwd' : projectKey(cwd)), encodeSegment(id));
|
|
158
162
|
}
|
|
159
|
-
|
|
160
163
|
/** 一个 generation 的绝对日志路径。 */
|
|
161
164
|
export function logPath(root, cwd, id, version, compression) {
|
|
162
|
-
|
|
165
|
+
return join(sessionDir(root, cwd, id), generationLogFilename(version, compression));
|
|
163
166
|
}
|