dsh-session-ops 0.1.4 → 0.2.0

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/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
- const text = JSON.stringify(body)
41
- if (options.contentLength === true) {
42
- res.writeHead(status, {
43
- 'content-type': 'application/json; charset=utf-8',
44
- 'cache-control': 'no-store',
45
- 'content-length': Buffer.byteLength(text),
46
- })
47
- } else {
48
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
49
- }
50
- res.end(text)
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
- const requireObject = options.requireObject === true
68
- const chunks = []
69
- let size = 0
70
- for await (const chunk of req) {
71
- size += chunk.length
72
- if (size > MAX_BODY_BYTES) throw new Error('request body too large')
73
- chunks.push(chunk)
74
- }
75
- if (chunks.length === 0) return {}
76
- const buffer = Buffer.concat(chunks).toString('utf8')
77
- if (!requireObject) return JSON.parse(buffer)
78
- const text = buffer.trim()
79
- if (text === '') return {}
80
- const parsed = JSON.parse(text)
81
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('request body must be a JSON object')
82
- return parsed
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
- const { prefix, methods, trustRequest, log, readBody = readJsonBody, contentLength = false } = options
102
- const reply = (res, status, body) => writeJson(res, status, body, { contentLength })
103
- return async (req, res) => {
104
- if (typeof trustRequest !== 'function' || !trustRequest(req)) {
105
- reply(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
106
- return
107
- }
108
- const url = new URL(req.url ?? '/', 'http://dsh.internal')
109
- const method = url.pathname.startsWith(`${prefix}/`) ? url.pathname.slice(prefix.length + 1) : undefined
110
- if (method === undefined || method.includes('/')) {
111
- reply(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method' } })
112
- return
113
- }
114
- const handler = methods[method]
115
- if (typeof handler !== 'function') {
116
- reply(res, 404, { ok: false, error: { code: 'not-found', message: `unknown method ${method}` } })
117
- return
118
- }
119
- try {
120
- const payload = req.method === 'GET' ? Object.fromEntries(url.searchParams) : await readBody(req)
121
- const value = await handler(payload)
122
- reply(res, 200, { ok: true, value })
123
- } catch (error) {
124
- if (typeof log === 'function') log('warn', `${method} failed: ${String(error?.message ?? error)}`)
125
- reply(res, 400, { ok: false, error: { code: 'bad-request', message: String(error?.message ?? error) } })
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
  }
@@ -23,9 +23,8 @@
23
23
  *
24
24
  * @module dsh-session-ops/store
25
25
  */
26
-
27
- export * from './paths.js'
28
- export * from './frames.js'
29
- export * from './registry.js'
30
- export * from './host.js'
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';
@@ -16,58 +16,63 @@
16
16
  *
17
17
  * @module dsh-session-ops/store/paths
18
18
  */
19
-
20
- import { readdirSync } from 'node:fs'
21
- import { join } from 'node:path'
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
- if (raw.length === 0) throw new Error('cannot encode an empty path segment')
33
- if (raw === '.') return '~002E'
34
- if (raw === '..') return '~002E~002E'
35
- let out = ''
36
- for (let i = 0; i < raw.length; i++) {
37
- const code = raw.charCodeAt(i)
38
- const ch = String.fromCharCode(code)
39
- if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch
40
- else out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
41
- }
42
- return out
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
- if (cwd.length === 0) throw new Error('cannot encode an empty project path')
52
- let readable = ''
53
- let separatorRun = false
54
- for (let i = 0; i < cwd.length; i++) {
55
- const code = cwd.charCodeAt(i)
56
- const ch = String.fromCharCode(code)
57
- if (ch === '/' || ch === '\\' || ch === ':') {
58
- if (!separatorRun) readable += '-'
59
- separatorRun = true
60
- } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
61
- readable += ch
62
- separatorRun = false
63
- } else {
64
- readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
65
- separatorRun = false
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
- let s = key
82
- if (s.startsWith('--')) s = s.slice(2)
83
- if (s.endsWith('--')) s = s.slice(0, -2)
84
- s = s.replace(/~([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
85
- s = s.replace(/-/g, '\\')
86
- // 盘符的冒号在目录键里没有单独转义(`C:\a` → `--C-a--`),补回来。
87
- return /^[A-Za-z]\\/.test(s) ? `${s[0]}:${s.slice(1)}` : s
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
- return name.endsWith('.jsonl.zstd') || name.endsWith('.jsonl')
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
- const match = GENERATION_NAME.exec(name)
110
- if (match === null) return undefined
111
- return { version: match[1] === undefined ? 0 : Number(match[1]), compressed: match[2] === '.zstd' }
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
- let entries
125
- try {
126
- entries = readdirSync(dir, { withFileTypes: true })
127
- } catch (error) {
128
- return { error: `cannot list session directory ${dir}: ${String(error?.message ?? error)}` }
129
- }
130
- const generations = []
131
- const others = []
132
- for (const entry of entries) {
133
- const parsed = parseGenerationName(entry.name)
134
- if (parsed === undefined) {
135
- others.push(entry.name)
136
- continue
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.push({ name: entry.name, version: parsed.version, compressed: parsed.compressed })
139
- }
140
- if (generations.length === 0) return { error: `no canonical session log in ${dir}`, others }
141
- generations.sort((left, right) => right.version - left.version)
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
- return compression === 'zstd' ? '.zstd' : ''
153
+ return compression === 'zstd' ? '.zstd' : '';
148
154
  }
149
-
150
155
  /** 一个 generation 的规范文件名(v0 不带 `vN` 段)。 */
151
156
  export function generationLogFilename(version, compression) {
152
- return `${version === 0 ? 'session' : `session.v${version}`}.jsonl${compressionSuffix(compression)}`
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
- return join(join(root, cwd === undefined ? '_no-cwd' : projectKey(cwd)), encodeSegment(id))
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
- return join(sessionDir(root, cwd, id), generationLogFilename(version, compression))
165
+ return join(sessionDir(root, cwd, id), generationLogFilename(version, compression));
163
166
  }