dsh-session-curator 0.1.1 → 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/README.md +4 -11
- package/README.zh-CN.md +4 -7
- package/lib/client.js +2660 -1425
- package/lib/host/fs-utils.js +105 -0
- package/lib/host/manifest.js +22 -0
- package/lib/host/open.js +104 -0
- package/lib/host/paths.js +47 -0
- package/lib/host/retention.js +24 -0
- package/lib/host/routes.js +134 -0
- package/lib/host/session-manager.js +477 -0
- package/lib/host/trash.js +279 -0
- package/lib/host/update.js +67 -0
- package/lib/index.js +17 -678
- package/package.json +19 -9
- package/scripts/activation.mjs +0 -358
- package/scripts/client.mjs +0 -569
- package/scripts/smoke.mjs +0 -382
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** 文件系统小工具 + 错误消息归一化。 */
|
|
2
|
+
import { promises as fsp } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
/** 任何抛出物 → 一句人话(宿主 logger 与端点错误信封都用它)。 */
|
|
5
|
+
export function messageOf(error) {
|
|
6
|
+
if (error instanceof Error)
|
|
7
|
+
return error.message;
|
|
8
|
+
return String(error);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 最多 `limit` 个任务同时在跑;返回值**按入参顺序**排列。
|
|
12
|
+
*
|
|
13
|
+
* 为什么不用 `Promise.all(items.map(...))`:快照要一次给上百个目录做 `readdir` + `stat`,
|
|
14
|
+
* 无上限并发会把磁盘打成一锅粥(Windows 上尤其明显,还会跟 DSH 自己的会话扫描抢 IO)。
|
|
15
|
+
* 这里只做"有上限的并发",超出的任务排队,单项抛错照原样往外冒(由调用方决定怎么办)。
|
|
16
|
+
*/
|
|
17
|
+
export async function mapLimit(items, limit, worker) {
|
|
18
|
+
const size = items.length;
|
|
19
|
+
const results = new Array(size);
|
|
20
|
+
if (size === 0)
|
|
21
|
+
return results;
|
|
22
|
+
const width = Math.max(1, Math.min(Math.floor(limit) || 1, size));
|
|
23
|
+
let next = 0;
|
|
24
|
+
const run = async () => {
|
|
25
|
+
while (next < size) {
|
|
26
|
+
const index = next;
|
|
27
|
+
next += 1;
|
|
28
|
+
results[index] = await worker(items[index], index);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
await Promise.all(Array.from({ length: width }, run));
|
|
32
|
+
return results;
|
|
33
|
+
}
|
|
34
|
+
/** 跨卷 rename 会抛 EXDEV,退回 copy + rm。 */
|
|
35
|
+
export async function moveDir(from, to) {
|
|
36
|
+
await fsp.mkdir(dirname(to), { recursive: true });
|
|
37
|
+
try {
|
|
38
|
+
await fsp.rename(from, to);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error?.code !== "EXDEV")
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
await fsp.cp(from, to, { recursive: true, force: true });
|
|
46
|
+
await fsp.rm(from, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
const DIR_STATS_CACHE = new Map();
|
|
49
|
+
/** 缓存上限:超过就整个清掉。磁盘目录数远小于它,这里只是防止长期运行无限增长。 */
|
|
50
|
+
const DIR_STATS_CACHE_LIMIT = 2000;
|
|
51
|
+
/** 给自检用:清掉缓存后重新统计(`scripts/smoke.mjs` 要断言"同一次快照内命中缓存")。 */
|
|
52
|
+
export function forgetDirStats() {
|
|
53
|
+
DIR_STATS_CACHE.clear();
|
|
54
|
+
}
|
|
55
|
+
/** 只统计顶层文件:会话目录里就是那几份日志。目录 mtime 没变就直接回缓存。 */
|
|
56
|
+
export async function dirStats(dir) {
|
|
57
|
+
let mtimeMs;
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
const info = await fsp.stat(dir);
|
|
61
|
+
mtimeMs = info.mtimeMs;
|
|
62
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { bytes: 0, files: 0 };
|
|
66
|
+
}
|
|
67
|
+
const cached = DIR_STATS_CACHE.get(dir);
|
|
68
|
+
if (cached !== undefined && cached.mtimeMs === mtimeMs && cached.entryCount === entries.length) {
|
|
69
|
+
return { bytes: cached.bytes, files: cached.files };
|
|
70
|
+
}
|
|
71
|
+
let bytes = 0;
|
|
72
|
+
let files = 0;
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (!entry.isFile())
|
|
75
|
+
continue;
|
|
76
|
+
try {
|
|
77
|
+
const stat = await fsp.stat(join(dir, entry.name));
|
|
78
|
+
bytes += stat.size;
|
|
79
|
+
files += 1;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// 竞态:文件刚好消失,忽略
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (DIR_STATS_CACHE.size >= DIR_STATS_CACHE_LIMIT)
|
|
86
|
+
DIR_STATS_CACHE.clear();
|
|
87
|
+
DIR_STATS_CACHE.set(dir, { mtimeMs, entryCount: entries.length, bytes, files });
|
|
88
|
+
return { bytes, files };
|
|
89
|
+
}
|
|
90
|
+
export async function readProjectionFile(file) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(await fsp.readFile(file, "utf8"));
|
|
93
|
+
const rows = parsed?.record?.rows;
|
|
94
|
+
const identity = parsed?.record?.identity;
|
|
95
|
+
const title = rows?.title?.val;
|
|
96
|
+
return {
|
|
97
|
+
title: typeof title === "string" && title !== "" ? title : null,
|
|
98
|
+
cwd: typeof identity?.cwd === "string" ? identity.cwd : null,
|
|
99
|
+
createdAt: typeof identity?.createdAt === "number" ? identity.createdAt : null
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { title: null, cwd: null, createdAt: null };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版本号与插件名:都从**自己的 manifest** 读。
|
|
3
|
+
*
|
|
4
|
+
* 路径按编译后的位置算:本文件编译成 `lib/host/manifest.js`,所以包根是它的上两级
|
|
5
|
+
* (无论插件是装在 `node_modules` 里还是被 junction 指过来,`import.meta.url` 都指向
|
|
6
|
+
* 真实文件)。名字用字面量而不是读 manifest:DSH 拿它当插件 id,一旦 manifest 读失败
|
|
7
|
+
* 也必须还是对的;版本号读不到就回 `null`(界面上显示成 "—"),不影响加载。
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
/** 插件名 == DSH 的插件 id == npm 包名。 */
|
|
11
|
+
export const PLUGIN_NAME = "dsh-session-curator";
|
|
12
|
+
function readLocalVersion() {
|
|
13
|
+
try {
|
|
14
|
+
const manifest = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
15
|
+
return typeof manifest?.version === "string" && manifest.version !== "" ? manifest.version : null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** 本地版本(`package.json` 的 `version`)。 */
|
|
22
|
+
export const LOCAL_VERSION = readLocalVersion();
|
package/lib/host/open.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「复制路径 / 在资源管理器中显示」的宿主侧:**只认自己算出来的目录**。
|
|
3
|
+
*
|
|
4
|
+
* 安全边界(这一段是这个文件存在的全部理由):
|
|
5
|
+
* - 路径**不接受客户端传**:只吃 `sessionId`,由宿主自己按 `<DSH_HOME>/sessions/<slug>/<id>`
|
|
6
|
+
* 或回收站目录拼出来。客户端就算传了别的路径也没用 —— 入参里根本没有路径字段。
|
|
7
|
+
* - 拼出来之后再 `resolve()` 一次并断言它落在**允许的两个根**(`<DSH_HOME>/sessions`、
|
|
8
|
+
* 回收站根)之下。这一道是为了挡住 `../` 之类的 id(线协议的 `idsOf()` 只保证是
|
|
9
|
+
* 非空字符串,不保证它不是一个相对路径)。
|
|
10
|
+
* - 只有 `target: "reveal"` 才会真的去叫系统文件管理器,而且只用**固定的可执行名**
|
|
11
|
+
* (`explorer.exe` / `open` / `xdg-open`),参数是那个已经校验过的目录。不拼 shell 字符串,
|
|
12
|
+
* 走 `spawn` + 参数数组,所以路径里的空格 / 引号 / `&` 都不会被解释成命令。
|
|
13
|
+
*
|
|
14
|
+
* 为什么不用 `shell.openPath`(Electron 的 API):宿主半区跑在 DSH 的 Electron 主进程里,
|
|
15
|
+
* 但插件是普通 ESM 模块,拿不到 `electron` 模块(那是 DSH 自己的依赖,junction 挂载下
|
|
16
|
+
* Node 按 realpath 解析根本找不到 —— 与 AGENTS.md §5 禁止裸导入 `@deepseek-ai/*` 同一条
|
|
17
|
+
* 理由)。所以这里退到"用平台自带的文件管理器命令",零依赖。
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { resolve, sep } from "node:path";
|
|
22
|
+
import { messageOf } from "./fs-utils.js";
|
|
23
|
+
import { sessionsRoot, trashRoot } from "./paths.js";
|
|
24
|
+
/** `path` / `reveal`:`reveal` 之外的任何值都当成 `path`(只读,最保守的那条)。 */
|
|
25
|
+
export function openTargetOf(value) {
|
|
26
|
+
return value === "reveal" ? "reveal" : "path";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 两个允许的根:会话目录与回收站。`resolve()` 之后比较,且要求"相等或以 `<root>+分隔符`
|
|
30
|
+
* 开头" —— 单比 `startsWith(root)` 会把 `<DSH_HOME>/sessions-evil` 放进来。
|
|
31
|
+
*/
|
|
32
|
+
function allowedRoots() {
|
|
33
|
+
return [resolve(sessionsRoot()), resolve(trashRoot())];
|
|
34
|
+
}
|
|
35
|
+
function inside(root, target) {
|
|
36
|
+
return target === root || target.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
37
|
+
}
|
|
38
|
+
/** 平台对应的文件管理器命令;不支持的平台返回 null(`path` 仍然可用)。 */
|
|
39
|
+
function revealCommand() {
|
|
40
|
+
if (process.platform === "win32")
|
|
41
|
+
return { command: "explorer.exe", args: [] };
|
|
42
|
+
if (process.platform === "darwin")
|
|
43
|
+
return { command: "open", args: [] };
|
|
44
|
+
if (process.platform === "linux")
|
|
45
|
+
return { command: "xdg-open", args: [] };
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 把文件管理器叫起来。**不等它退出**:`explorer.exe` 会立刻返回、`xdg-open` 可能一直活着,
|
|
50
|
+
* 等它就会把端点请求挂住。所以 `detached` + `stdio: "ignore"` + `unref()`,
|
|
51
|
+
* 只监听 `error`(命令不存在那种);真正"有没有打开"由用户自己看得到。
|
|
52
|
+
*/
|
|
53
|
+
function reveal(path) {
|
|
54
|
+
const command = revealCommand();
|
|
55
|
+
if (command === null)
|
|
56
|
+
return { ok: false, reason: `不支持的平台:${process.platform}` };
|
|
57
|
+
try {
|
|
58
|
+
const child = spawn(command.command, [...command.args, path], { detached: true, stdio: "ignore" });
|
|
59
|
+
child.on("error", () => {
|
|
60
|
+
// 没有文件管理器(无头环境)不该让请求失败:路径已经回了,界面提示用户手动打开。
|
|
61
|
+
});
|
|
62
|
+
child.unref();
|
|
63
|
+
return { ok: true };
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
return { ok: false, reason: messageOf(error) };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* `open` 端点:算路径 → 校验 → (可选)叫文件管理器。
|
|
71
|
+
*
|
|
72
|
+
* `sessionId` 必须能在磁盘或回收站里找到;找不到就抛错,由信封兜住(与 `describe` 一致)。
|
|
73
|
+
*/
|
|
74
|
+
export async function openSession(host, ids, target) {
|
|
75
|
+
const sessionId = ids[0];
|
|
76
|
+
if (typeof sessionId !== "string" || sessionId === "")
|
|
77
|
+
throw new Error("缺少会话 id");
|
|
78
|
+
// 磁盘优先,其次回收站:同一个 id 不可能同时在两处(删除时目录被搬走了)。
|
|
79
|
+
const dirs = await host.scanSessionDirs();
|
|
80
|
+
let path = dirs.get(sessionId)?.dir ?? null;
|
|
81
|
+
if (path === null) {
|
|
82
|
+
const inTrash = resolve(trashRoot(), sessionId);
|
|
83
|
+
if (existsSync(inTrash))
|
|
84
|
+
path = inTrash;
|
|
85
|
+
}
|
|
86
|
+
if (path === null)
|
|
87
|
+
throw new Error("找不到这个会话的日志目录(磁盘和回收站里都没有)");
|
|
88
|
+
const resolved = resolve(path);
|
|
89
|
+
const roots = allowedRoots();
|
|
90
|
+
if (!roots.some((root) => inside(root, resolved))) {
|
|
91
|
+
// 到不了这里才说明 id 里有 `../` 之类的东西 —— 直接拒绝,别去碰根目录外面的路径。
|
|
92
|
+
throw new Error("拒绝打开:这个路径不在 DSH 的会话目录里");
|
|
93
|
+
}
|
|
94
|
+
if (!existsSync(resolved))
|
|
95
|
+
throw new Error("日志目录已经不在了");
|
|
96
|
+
if (target !== "reveal")
|
|
97
|
+
return { sessionId, path: resolved, revealed: false };
|
|
98
|
+
const opened = reveal(resolved);
|
|
99
|
+
return opened.ok
|
|
100
|
+
? { sessionId, path: resolved, revealed: true }
|
|
101
|
+
: { sessionId, path: resolved, revealed: false, reason: opened.reason };
|
|
102
|
+
}
|
|
103
|
+
/** 允许的根(导出给自检断言"路径校验真的挡得住 `../`")。 */
|
|
104
|
+
export { allowedRoots, inside as insideRoot };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH home 解析与插件用到的全部路径。
|
|
3
|
+
*
|
|
4
|
+
* 这里刻意**不 import** `@deepseek-ai/dsh-home-paths`:那个包只在 DSH 自己的
|
|
5
|
+
* node_modules 里,而插件可能以 junction/symlink 形式挂在 profile 下 —— 此时 Node 会
|
|
6
|
+
* 按 realpath 解析裸标识符,从插件源码目录向上找不到它,插件就加载不了。语义与那个包
|
|
7
|
+
* 一致(`$DSH_HOME` 优先,否则 `~/.dsh`),几行就够,换来的是"插件放哪都能跑"。
|
|
8
|
+
*/
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
export function resolveDshHome() {
|
|
12
|
+
const configured = process.env.DSH_HOME;
|
|
13
|
+
if (typeof configured === "string" && configured.trim() !== "") {
|
|
14
|
+
const expanded = configured.trim();
|
|
15
|
+
if (expanded === "~")
|
|
16
|
+
return homedir();
|
|
17
|
+
if (expanded.startsWith("~/") || expanded.startsWith("~\\"))
|
|
18
|
+
return resolve(join(homedir(), expanded.slice(2)));
|
|
19
|
+
return resolve(expanded);
|
|
20
|
+
}
|
|
21
|
+
return resolve(join(homedir(), ".dsh"));
|
|
22
|
+
}
|
|
23
|
+
/** 每次都重新解析:`smoke.mjs` 会在同一进程里换掉 `DSH_HOME` 来用临时 home。 */
|
|
24
|
+
export function dshHomePath(...segments) {
|
|
25
|
+
return join(resolveDshHome(), ...segments);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 回收站根目录:`<DSH_HOME>/storages/session-manager/trash`。
|
|
29
|
+
*
|
|
30
|
+
* 这里的 `session-manager` 是**故意不改的旧名**:插件从 `dsh-session-manager` 改名成
|
|
31
|
+
* `dsh-session-curator` 时,回收站里可能还躺着用户已经删掉的会话(本机就有一个)——
|
|
32
|
+
* 改路径会把它们变成孤儿。数据目录用历史名、其余 id 用新名,是权衡后的结果。
|
|
33
|
+
*/
|
|
34
|
+
export const TRASH_SUBPATH = ["storages", "session-manager", "trash"];
|
|
35
|
+
/** 回收站条目的元数据:原 slug / cwd / 标题 / 删除时间 / 删除前是否已归档。 */
|
|
36
|
+
export const META_FILE = "__trash.json";
|
|
37
|
+
/** 从投影缓存搬进来的那份文件(恢复时要搬回原位)。 */
|
|
38
|
+
export const PROJ_FILE = "__projcache.json";
|
|
39
|
+
export function trashRoot() {
|
|
40
|
+
return dshHomePath(...TRASH_SUBPATH);
|
|
41
|
+
}
|
|
42
|
+
export function sessionsRoot() {
|
|
43
|
+
return dshHomePath("sessions");
|
|
44
|
+
}
|
|
45
|
+
export function projCacheFile(sessionId) {
|
|
46
|
+
return dshHomePath("storages", "session_projcache", "sessions", `${sessionId}.json`);
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 保留天数 → 截止时间戳:**严格早于**它的条目才算过期。
|
|
3
|
+
*
|
|
4
|
+
* 上限 3650 天(10 年)只是防呆:`days` 来自线协议,客户端算错、或有人手工构造请求时,
|
|
5
|
+
* 别让它退化成"把整个回收站清空";`NaN` / 负数一律夹到 1 天(而不是 0:0 会让截止时间
|
|
6
|
+
* 变成"此刻",语义上等于清空)。
|
|
7
|
+
*/
|
|
8
|
+
export function retentionCutoff(days) {
|
|
9
|
+
const safe = Number.isFinite(days) ? Math.min(Math.max(Math.floor(days), 1), 3650) : 1;
|
|
10
|
+
return Date.now() - safe * 24 * 60 * 60 * 1000;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 一条回收站条目该不该被"清理 N 天前"带走。
|
|
14
|
+
*
|
|
15
|
+
* 三个条件缺一不可:
|
|
16
|
+
* - `restorable`:老版本留下的、没有 `__trash.json` 的条目连原 slug 都不知道,
|
|
17
|
+
* `deletedAt` 也就无从谈起(`listTrash()` 会给它 0)。它们只能由用户在界面上
|
|
18
|
+
* 逐条「永久删除」,绝不能被一个按时间的批量操作顺手带走;
|
|
19
|
+
* - `deletedAt > 0`:同上,缺删除时间的条目不参与时间判断;
|
|
20
|
+
* - `deletedAt < cutoff`:**严格**早于截止时间,边界那一条留着。
|
|
21
|
+
*/
|
|
22
|
+
export function isExpired(row, cutoff) {
|
|
23
|
+
return row.restorable && row.deletedAt > 0 && row.deletedAt < cutoff;
|
|
24
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 传输 / 分发:十一个端点 → 十一条精确 Fetch 路由。
|
|
3
|
+
*
|
|
4
|
+
* ── 为什么**不用** `ctx.connection.rpc.handle()`(踩过的坑,改回去之前先读)──
|
|
5
|
+
*
|
|
6
|
+
* 它内部把路由交给 `owner.effect(() => owner.webServer.register(route))`,而那个
|
|
7
|
+
* `owner` 是 `this.ctx` —— **Connection 服务自己的 ctx**,不是读服务的插件 ctx。
|
|
8
|
+
* cordis 的服务调用会带 shadow(`Context[symbols.shadow]` 指向提供者作用域),所以
|
|
9
|
+
* 服务解析从"提供者的 fiber"开始;Connection 自己的 inject 只有 `["credentials"]`,
|
|
10
|
+
* 于是必然抛:
|
|
11
|
+
*
|
|
12
|
+
* cannot get property "webServer" without inject
|
|
13
|
+
* (@deepseek-ai/dsh-client-connection/lib/index.js:656)
|
|
14
|
+
*
|
|
15
|
+
* 结果:插件 fiber 直接变 `failed`、一条路由都没挂上,客户端每个 POST 落到 SPA 静态
|
|
16
|
+
* 兜底,由 `@deepseek-ai/dsh-host-frontend-static` 对非 GET/HEAD 回 **HTTP 405** ——
|
|
17
|
+
* 就是 `transport failure for /session-manager/trash: HTTP 405`。
|
|
18
|
+
* 注意:**在插件自己的 inject 里加 `webServer` 修不了它**,因为报错的那个 ctx 不是插件的。
|
|
19
|
+
*
|
|
20
|
+
* 改走同样由 Connection 公布的 `ctx.connection.fetch.register()`:精确 Fetch 路由挂在
|
|
21
|
+
* 共享 `/api` 前缀下,`admit()`(Host/Origin/`sec-fetch-site` + 浏览器会话 cookie)与
|
|
22
|
+
* 请求体上限都仍由 Connection 的 `/api` 前缀路由先执行,插件依旧零鉴权代码。
|
|
23
|
+
*/
|
|
24
|
+
import { messageOf } from "./fs-utils.js";
|
|
25
|
+
/** DSH 的共享 API 通道:唯一带 Host/Origin 信任栅栏与浏览器会话鉴权的 HTTP 面。 */
|
|
26
|
+
export const API_CHANNEL = "/api";
|
|
27
|
+
/** 频道下属于本插件的路径前缀(`/api/session-curator/<endpoint>`)。 */
|
|
28
|
+
export const ROUTE_PREFIX = "session-curator";
|
|
29
|
+
/** 端点只读 / 只写,一律 POST(Connection 的精确路由按方法集合匹配)。 */
|
|
30
|
+
const ROUTE_METHODS = ["POST"];
|
|
31
|
+
/** 一个频道(`/api/session-curator`):精确路由表按完整 pathname 命中,所以每个端点一条。 */
|
|
32
|
+
export const ENDPOINTS = {
|
|
33
|
+
snapshot: (manager) => manager.snapshot(),
|
|
34
|
+
describe: (manager, payload) => manager.describe(payload.ids ?? []),
|
|
35
|
+
archive: (manager, payload) => manager.archive(payload.ids ?? [], payload.stopActivity),
|
|
36
|
+
unarchive: (manager, payload) => manager.unarchive(payload.ids ?? []),
|
|
37
|
+
pin: (manager, payload) => manager.pin(payload.ids ?? []),
|
|
38
|
+
unpin: (manager, payload) => manager.unpin(payload.ids ?? []),
|
|
39
|
+
trash: (manager, payload) => manager.trash(payload.ids ?? []),
|
|
40
|
+
restore: (manager, payload) => manager.restore(payload.ids ?? []),
|
|
41
|
+
purge: (manager, payload) => manager.purge(payload.ids ?? []),
|
|
42
|
+
// 保留策略的两条都只吃 `days`(不是 id 列表):预览只读,清理是永久删除。
|
|
43
|
+
"trash-stats": (manager, payload) => manager.trashStats(daysOf(payload)),
|
|
44
|
+
"purge-older-than": (manager, payload) => manager.purgeOlderThan(daysOf(payload)),
|
|
45
|
+
// 磁盘占用视图(只读)与"复制路径 / 在资源管理器中显示"。
|
|
46
|
+
usage: (manager) => manager.usage(),
|
|
47
|
+
// `target` 只认字面量 `"reveal"`;其余一律当 `"path"`(只读,最保守的那条)。
|
|
48
|
+
open: (manager, payload) => manager.open(payload.ids ?? [], payload.target === "reveal" ? "reveal" : "path"),
|
|
49
|
+
"check-update": (manager, payload) => manager.checkUpdate(payload.force === true)
|
|
50
|
+
};
|
|
51
|
+
/** 只接受字符串数组;其余一律忽略,避免把宿主路径当 id 传来的越权尝试。 */
|
|
52
|
+
export function idsOf(payload) {
|
|
53
|
+
return Array.isArray(payload.ids) ? payload.ids.filter((value) => typeof value === "string" && value !== "") : [];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 保留天数:越界 / 非数字一律退回 **30 天**(界面的默认值)。
|
|
57
|
+
*
|
|
58
|
+
* 这里只做"是不是个正常数字"的粗校验,真正的夹取在 `retention.ts` 的
|
|
59
|
+
* `retentionCutoff()`(预览与真删共用那一份)。为什么退回 30 而不是 0:`0` 会让截止时间
|
|
60
|
+
* 变成"此刻",等于把整个回收站当成过期 —— 缺字段的请求绝不能有这种后果。
|
|
61
|
+
*/
|
|
62
|
+
export function daysOf(payload) {
|
|
63
|
+
const value = payload.days;
|
|
64
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
65
|
+
return 30;
|
|
66
|
+
return Math.min(Math.max(Math.floor(value), 1), 3650);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 一个端点处理器。返回 Connection 的固定信封:处理器自身的业务失败走
|
|
70
|
+
* `{ok:false, error}`,端点内部的逐项失败则留在 `value.results` 里。
|
|
71
|
+
*/
|
|
72
|
+
export async function handle(manager, endpoint, payload) {
|
|
73
|
+
const run = ENDPOINTS[endpoint];
|
|
74
|
+
if (run === undefined) {
|
|
75
|
+
return { ok: false, error: { code: "session-curator/unknown-endpoint", message: `unknown endpoint ${endpoint}`, details: {} } };
|
|
76
|
+
}
|
|
77
|
+
const safe = payload !== null && typeof payload === "object" ? payload : {};
|
|
78
|
+
// 越过线协议边界之后客户端发来的一切都不可信:这里如实标成 EndpointPayload,
|
|
79
|
+
// 具体字段仍由各处理器自己判(`idsOf()` 已经归一化过 ids)。
|
|
80
|
+
const normalized = { ...safe, ids: idsOf(safe) };
|
|
81
|
+
try {
|
|
82
|
+
return { ok: true, value: await run(manager, normalized) };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return { ok: false, error: { code: "session-curator/error", message: messageOf(error), details: {} } };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** `{type:'server-response', rpcId, result}` —— 与 Connection 自己回的信封逐字一致。 */
|
|
89
|
+
function envelope(rpcId, result) {
|
|
90
|
+
return Response.json({ type: "server-response", rpcId, result });
|
|
91
|
+
}
|
|
92
|
+
function badRequest(rpcId, message) {
|
|
93
|
+
// 与 Connection 的 `invalidEnvelopeResponse` 一致:坏信封也回 200 + 错误信封,
|
|
94
|
+
// 这样客户端读到的是人话,而不是一个传输层异常。
|
|
95
|
+
return envelope(rpcId ?? "invalid-request", {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: { code: "session-curator/bad-request", message, details: {} }
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** `/api` 已在前面做完信任与鉴权,这里只校验信封、分发、回包。 */
|
|
101
|
+
export async function respond(manager, endpoint, request) {
|
|
102
|
+
let body;
|
|
103
|
+
try {
|
|
104
|
+
body = await request.json();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return badRequest(null, "body is not JSON");
|
|
108
|
+
}
|
|
109
|
+
const message = body !== null && typeof body === "object" ? body : null;
|
|
110
|
+
const rpcId = typeof message?.rpcId === "string" ? message.rpcId : null;
|
|
111
|
+
if (rpcId === null || message?.type !== "client-request" || typeof message.method !== "string") {
|
|
112
|
+
return badRequest(rpcId, "invalid client-request message");
|
|
113
|
+
}
|
|
114
|
+
const target = `${ROUTE_PREFIX}/${endpoint}`;
|
|
115
|
+
if (message.method !== target) {
|
|
116
|
+
return badRequest(rpcId, `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(target)}`);
|
|
117
|
+
}
|
|
118
|
+
return envelope(rpcId, await handle(manager, endpoint, message.payload));
|
|
119
|
+
}
|
|
120
|
+
/** 为每个端点挂一条精确路由;注册挂在**插件自己的 fiber** 上,卸载时一起摘掉。 */
|
|
121
|
+
export function registerRoutes(ctx, manager) {
|
|
122
|
+
// 只知道"精确路由登记处"这一个面;命名避开全局 fetch。
|
|
123
|
+
const fetchRoutes = ctx.connection.fetch;
|
|
124
|
+
for (const endpoint of Object.keys(ENDPOINTS)) {
|
|
125
|
+
const route = {
|
|
126
|
+
path: `${API_CHANNEL}/${ROUTE_PREFIX}/${endpoint}`,
|
|
127
|
+
methods: ROUTE_METHODS,
|
|
128
|
+
requestBody: "buffered",
|
|
129
|
+
fetch: (request) => respond(manager, endpoint, request)
|
|
130
|
+
};
|
|
131
|
+
// `registerFetchRoute` 用的是调用方的 `owner.effect`,所以这里传的是插件的 ctx。
|
|
132
|
+
ctx.effect(() => fetchRoutes.register(route), `dsh-session-curator: ${route.path}`);
|
|
133
|
+
}
|
|
134
|
+
}
|