@morlay/dsh-client-modules 0.0.0-stage → 0.2.6

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.
@@ -0,0 +1,43 @@
1
+ // client 半只有一个职责:把上游 `modules` 服务补上。判据是"上游真正用到的那两个成员形状对"——
2
+ // 上游 `ui-settings-general` 把 `entries.releaseChange` 当 `useSyncExternalStore` 的快照读、
3
+ // 用 `entries.dismissRelease()` 关掉提示,动态插件运行器用 `invalidate()` 做模块失效。
4
+ import { Context } from "@deepseek-ai/cordis";
5
+ import { describe, expect, it } from "vitest";
6
+ import { apply } from "../index.ts";
7
+
8
+ interface ModulesService {
9
+ readonly entries: {
10
+ readonly releaseChange: {
11
+ getSnapshot: () => unknown;
12
+ subscribe: (listener: () => void) => () => void;
13
+ };
14
+ readonly dismissRelease: () => void;
15
+ };
16
+ readonly invalidate: (id: string) => void;
17
+ }
18
+
19
+ async function modulesOf(): Promise<ModulesService> {
20
+ const ctx = new Context();
21
+ await ctx.plugin({ apply });
22
+ return ctx.get("modules") as unknown as ModulesService;
23
+ }
24
+
25
+ describe("client 半的 modules 服务面", () => {
26
+ it("releaseChange 是恒 undefined 的可订阅快照(我们这条链没有跨 Host 版本的插件更新)", async () => {
27
+ const modules = await modulesOf();
28
+ expect(modules.entries.releaseChange.getSnapshot()).toBeUndefined();
29
+ const unsubscribe = modules.entries.releaseChange.subscribe(() => {});
30
+ expect(typeof unsubscribe).toBe("function");
31
+ unsubscribe();
32
+ });
33
+
34
+ it("dismissRelease 与 invalidate 可调用(提示已读与模块失效在本链上都是空操作)", async () => {
35
+ const modules = await modulesOf();
36
+ expect(() => {
37
+ modules.entries.dismissRelease();
38
+ }).not.toThrow();
39
+ expect(() => {
40
+ modules.invalidate("plugin-x");
41
+ }).not.toThrow();
42
+ });
43
+ });
@@ -0,0 +1,65 @@
1
+ // 终端恢复兜底:宿主已经不认的终端 id(`closedIds` / 重启后消失)不能再拿来 `retain` —— 而侧栏布局与
2
+ // 客户端 `bindings` 会一直把它带回来,`TerminalView` 的 `createWhenMissing` 因此恒为 false,面板永远停在
3
+ // "此终端已不存在,请新建终端"。这里守的是"对账"这一步:清单里没有这个 id 就当新 tab(删 binding、丢掉
4
+ // 旧视图),让上游按其本意分配一个新终端;清单里有则原样透传(恢复能力不变)。
5
+ import { Context } from "@deepseek-ai/cordis";
6
+ import { describe, expect, it } from "vitest";
7
+ import { apply } from "../index.ts";
8
+
9
+ interface Fake {
10
+ readonly service: unknown;
11
+ readonly bindings: Map<string, string>;
12
+ readonly views: Map<string, Map<string, unknown>>;
13
+ }
14
+
15
+ /** 上游 `ClientTerminals` 的最小替身:`view` 在**原型**上(我们的兜底改的就是原型)。 */
16
+ function fakeTerminals(alive: boolean): Fake {
17
+ const bindings = new Map<string, string>([["s|content", "terminal-1"]]);
18
+ const inventories = new Map([["s", { items: new Map(alive ? [["terminal-1", {}]] : []) }]]);
19
+ const views = new Map([["s", new Map<string, unknown>([["key", {}]])]]);
20
+ class Terminals {
21
+ readonly bindings = {
22
+ get: (sessionId: string, contentId: string): string | undefined =>
23
+ bindings.get(`${sessionId}|${contentId}`),
24
+ delete: (sessionId: string, contentId: string): void => {
25
+ bindings.delete(`${sessionId}|${contentId}`);
26
+ },
27
+ };
28
+ readonly inventories = inventories;
29
+ readonly views = views;
30
+ view(...args: unknown[]): unknown {
31
+ return args;
32
+ }
33
+ }
34
+ return { service: new Terminals(), bindings, views };
35
+ }
36
+
37
+ async function loaded(fake: Fake): Promise<{ view: (...args: unknown[]) => unknown }> {
38
+ const ctx = new Context();
39
+ ctx.provide("webTerminals", fake.service as never);
40
+ await ctx.plugin({ apply });
41
+ return {
42
+ view: (...args: unknown[]) =>
43
+ (fake.service as { view: (...a: unknown[]) => unknown }).view(...args),
44
+ };
45
+ }
46
+
47
+ describe("终端恢复兜底", () => {
48
+ it("宿主已经不认这个 id 时:当新 tab——删 binding、丢掉旧视图、不把 id 传下去", async () => {
49
+ const fake = fakeTerminals(false);
50
+ const { view } = await loaded(fake);
51
+ const args = view("s", "key", "content", undefined, undefined);
52
+ expect(fake.bindings.has("s|content")).toBe(false);
53
+ expect(fake.views.get("s")?.has("key")).toBe(false);
54
+ expect(args).toEqual(["s", "key", "content", undefined, undefined]);
55
+ });
56
+
57
+ it("清单里还在时:原样透传(恢复能力不变)", async () => {
58
+ const fake = fakeTerminals(true);
59
+ const { view } = await loaded(fake);
60
+ const args = view("s", "key", "content", undefined, undefined);
61
+ expect(fake.bindings.has("s|content")).toBe(true);
62
+ expect(fake.views.get("s")?.has("key")).toBe(true);
63
+ expect(args).toEqual(["s", "key", "content", undefined, undefined]);
64
+ });
65
+ });
@@ -0,0 +1,101 @@
1
+ // client 半:两件事,都是**把上游那条链缺的面补上**。
2
+ //
3
+ // 1. `modules` 服务面:上游 `ui-settings-general` 的 client 插件 inject 它(有它就忽略 peer),
4
+ // 我们的装配停用了上游 `client-modules` 行,所以这里补一个最小实现(见文件头注释与 README)。
5
+ // 2. 终端恢复兜底:上游 `api-terminal-controller` 的客户端把「侧栏 tab → 终端 id」记在 `bindings` 里
6
+ // (`dsh.terminal.binding.v1.[sessionId, contentId]`),而 Host 侧 `close()` 会把那个 id 记进
7
+ // `closedIds` 并且**永不再接受**(`retain()` 回 `terminal/unavailable`)。布局与 binding 会把这个
8
+ // 已失效的 id 一直带回来,`TerminalView` 的 `createWhenMissing` 只在「没有 saved id」时才分配新终端
9
+ // —— 于是面板永远停在"此终端已不存在,请新建终端",点"新建终端"复用的还是它。
10
+ import type { Context } from "@deepseek-ai/cordis";
11
+
12
+ /** 恒 `undefined` 的可订阅快照(`useSyncExternalStore` 的入参形状)。 */
13
+ const NO_RELEASE_CHANGE = {
14
+ getSnapshot: (): undefined => undefined,
15
+ subscribe: () => () => {},
16
+ };
17
+
18
+ /**
19
+ * 浏览器侧的根插件。
20
+ *
21
+ * @param ctx - 浏览器侧根上下文。
22
+ */
23
+ export function apply(ctx: Context): void {
24
+ ctx.reflect.provide("modules", {
25
+ entries: {
26
+ releaseChange: NO_RELEASE_CHANGE,
27
+ dismissRelease: () => {},
28
+ },
29
+ invalidate: () => {},
30
+ });
31
+ installTerminalRecovery(ctx);
32
+ }
33
+
34
+ /** 终端 id 与它在宿主清单里的存在性(清单未加载时按"不在"处理,见下面的取舍)。 */
35
+ interface TerminalInventoryLike {
36
+ readonly items?: Map<string, unknown>;
37
+ }
38
+ interface TerminalsLike {
39
+ readonly bindings?: {
40
+ get?: (sessionId: string, contentId: string) => string | undefined;
41
+ delete?: (sessionId: string, contentId: string) => void;
42
+ };
43
+ readonly inventories?: Map<string, TerminalInventoryLike>;
44
+ readonly views?: Map<string, Map<string, unknown>>;
45
+ }
46
+ type ViewFn = (
47
+ sessionId: string,
48
+ key: string,
49
+ contentId: string,
50
+ terminalId?: string,
51
+ shellPath?: string,
52
+ ) => unknown;
53
+
54
+ /**
55
+ * 终端恢复兜底:`view` 之前先**对账**,把宿主已经不认的终端 id 当新 tab 处理。
56
+ *
57
+ * 上游 `TerminalView` 的语义是「新 tab 才分配」(`createWhenMissing = saved === undefined`),而
58
+ * `saved = terminalId ?? bindings.get(sessionId, contentId)`:UI 传的 `terminalId` 实测是 `undefined`,
59
+ * 所以决定权全在 `bindings` —— 它不随宿主侧关闭状态失效。清单里没有这个 id(宿主重启过、或那个终端被
60
+ * 关过)时删掉这条 binding,让上游按新 tab 分配;清单里有则原样透传,恢复能力不变。清单**没加载**时按
61
+ * 「不在」处理:那时用户要的就是打开一个终端,让它重新分配比卡在死状态里强。
62
+ *
63
+ * 注意:`bindings` / `inventories` 是上游 Service 的私有字段,`view` 在其原型上(实例属性覆盖无效,
64
+ * 见 cordis 的 Service 代理)——所以这里改的是原型。这是**绕过上游缺陷**,等上游把 binding 与关闭状态
65
+ * 对上账就该删掉。
66
+ *
67
+ * @param ctx - 浏览器侧根上下文。
68
+ */
69
+ function installTerminalRecovery(ctx: Context): void {
70
+ ctx.inject(["webTerminals"], (scope) => {
71
+ const terminals = scope.get("webTerminals") as TerminalsLike | undefined;
72
+ if (terminals === undefined) return;
73
+ const proto = Object.getPrototypeOf(terminals) as {
74
+ view?: ViewFn;
75
+ __terminalRecovery?: boolean;
76
+ };
77
+ if (proto.view === undefined || proto.__terminalRecovery === true) return;
78
+ proto.__terminalRecovery = true;
79
+ const original = proto.view;
80
+ proto.view = function (
81
+ this: TerminalsLike,
82
+ sessionId: string,
83
+ key: string,
84
+ contentId: string,
85
+ terminalId?: string,
86
+ shellPath?: string,
87
+ ) {
88
+ const bound = terminalId ?? this.bindings?.get?.(sessionId, contentId);
89
+ const alive =
90
+ bound === undefined || this.inventories?.get(sessionId)?.items?.has(bound) === true;
91
+ if (!alive) {
92
+ this.bindings?.delete?.(sessionId, contentId);
93
+ // 已存在的视图**不会**重新构造(`view()` 里只在没有它时才 new),而它的 `createWhenMissing` 早就是
94
+ // false 了——不丢掉它,清 binding 也没用。
95
+ this.views?.get(sessionId)?.delete(key);
96
+ return original.call(this, sessionId, key, contentId, undefined, shellPath);
97
+ }
98
+ return original.call(this, sessionId, key, contentId, terminalId, shellPath);
99
+ };
100
+ });
101
+ }
package/src/config.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * dev 形态的分流配置:profile 的 `dsh.dev.exclude` 点名的包**不算开发模块**。
3
+ *
4
+ * 那些包(如 `@deepseek-ai/*`)在 dev 期间不打算改,于是不值得为它们付"逐文件请求"的代价——
5
+ * 走按包聚合(一个包一份产物、共享 chunk),换掉模块级 HMR;没被点名的包是正在改的,保持逐文件走 vite。
6
+ */
7
+ import { readFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+
10
+ /** 一条 glob(只支持 `*`,够用)→ 正则。 */
11
+ function globToRegExp(pattern: string): RegExp {
12
+ const escaped = pattern.replace(/[/\\^$+?.()|[\]{}]/gu, "\\$&").replaceAll("*", ".*");
13
+ return new RegExp(`^${escaped}$`);
14
+ }
15
+
16
+ /**
17
+ * 读 profile 的 `dsh.dev.exclude`。
18
+ *
19
+ * profile 根按这个顺序找:启动器给的 `DSH_DESKTOP_DEV_PROFILE_DIR`(桌面形态);`DSH_HOME` 的父目录
20
+ * (它总是 `<profile>/.dsh-store`,web 形态也有);当前目录。
21
+ *
22
+ * @returns 匹配"非开发模块"的正则表;**没配就是全部**(见函数末尾的说明)。
23
+ */
24
+ /**
25
+ * 桌面形态(含 bundle):**全部按非开发模块**处理。
26
+ *
27
+ * 它的页面跑在自定义协议下(`dsh-…://app/`),请求经壳的字节管道进来——每个逐文件请求都要走一趟这条路,
28
+ * 几百个源码模块叠起来就是"刷新一次卡几十秒"。桌面形态本来也不需要"改哪个包只看哪个包"的细粒度取舍:
29
+ * 全部按包预编译,改包时那一份重建 + 通知页面刷新(见壳的 ws 端点)。
30
+ */
31
+ /**
32
+ * 是不是**开发形态**(`dshdev web` / `dshdev desktop`;打包产物不是)。
33
+ *
34
+ * `DSH_DEV_FORM` 由 `dshdev` 注入给宿主(壳起宿主时会过滤掉 `DSH_DESKTOP_*`,那些是 Electron 侧的元数据;
35
+ * web 形态没有壳,所以这条由 CLI 直接给)——值只说哪种形态,**有没有值**才是判据。这一判据**一个 home**:
36
+ * 预编译策略(本文件)与"这条链的根在哪"(`src/index.ts` 的 `repoRootOf`)都用它。
37
+ *
38
+ * @returns dev 形态为 true;bundle(打包产物)为 false。
39
+ */
40
+ export function isDevForm(): boolean {
41
+ return process.env["DSH_DEV_FORM"] !== undefined;
42
+ }
43
+
44
+ export async function devExcluded(profileDir: string): Promise<RegExp[]> {
45
+ // **只有 dev 形态才谈"哪些包不打算改"**:`dsh.dev.exclude` 是 dev 的取舍(点名的包不值得付逐文件请求的
46
+ // 代价,走按包聚合),打包形态(含 `dshdev --prod` 那份解析面)没有"改哪个包"这回事——一律全部预编译。
47
+ if (!isDevForm()) return [/.*/u];
48
+ try {
49
+ const manifest = JSON.parse(await readFile(join(profileDir, "package.json"), "utf8")) as {
50
+ dsh?: { dev?: { exclude?: string[] } };
51
+ };
52
+ const patterns = (manifest.dsh?.dev?.exclude ?? []).map(globToRegExp);
53
+ if (patterns.length > 0) return patterns;
54
+ } catch {
55
+ // 读不到就算没配。
56
+ }
57
+ // **读不到配置就是"不打算改任何包"**:全部走按包预编译。这正是两种形态"完美复用同一条链"的地方。
58
+ return [/.*/u];
59
+ }
60
+
61
+ /**
62
+ * 该包是否走**聚合**(即"非开发模块")。
63
+ *
64
+ * @param name - 包名。
65
+ * @param patterns - {@link devExcluded} 的结果。
66
+ * @returns 命中任一 glob 即为真。
67
+ */
68
+ export function isAggregated(name: string, patterns: readonly RegExp[]): boolean {
69
+ return patterns.some((pattern) => pattern.test(name));
70
+ }
package/src/dirs.ts ADDED
@@ -0,0 +1,97 @@
1
+ // 「格」的定义:装配根 / 解析根 / 数据根,以及**由它们派生**的那些路径。
2
+ //
3
+ // 术语与两种形态的取值组合见 `../../../../devpackages/dshdev/.agents/GLOSSARY.md`。这里的规矩是:
4
+ // **路径只在这里拼**——别处取名字(`storeDir()`、`profileManifest()`),不手写
5
+ // `join(x, "node_modules", ".pnpm")` 这种串。那些串散在各处时,改一格要满仓找,且两种形态各错一次。
6
+ import { readFile } from "node:fs/promises";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { CLIENT_MODULES_CACHE } from "./bundle.ts";
10
+ import { isDevForm } from "./config.ts";
11
+ import { workspaceRootOf } from "./resolve.ts";
12
+
13
+ /** 本包自己的根(页面的骨架、`node-stub` 与依赖解析的兜底基准都从它推)。 */
14
+ export function packageRoot(): string {
15
+ return dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
16
+ }
17
+
18
+ /** 页面与入口所在的目录(vite 的 root):本包的 `app-boot`。 */
19
+ export function appBootDir(): string {
20
+ return join(packageRoot(), "app-boot");
21
+ }
22
+
23
+ /** 本包的清单(依赖解析的一个基准:这条链的运行期依赖声明在它里面)。 */
24
+ export function packageManifestPath(): string {
25
+ return join(packageRoot(), "package.json");
26
+ }
27
+
28
+ /**
29
+ * 装配根(profileDir):这份部署装配出来的那一格。
30
+ *
31
+ * dev 是工作区里的 app 目录(`dshdev` 给的 `DSH_DESKTOP_DEV_PROFILE_DIR`),bundle 是宿主进程的 cwd
32
+ * (壳把宿主的 cwd 设成 `<dataDir>/profiles/<name>`)。
33
+ *
34
+ * @returns 第一个带 `package.json` 的候选取值;都没有时退回 cwd。
35
+ */
36
+ export async function profileDir(): Promise<string> {
37
+ const candidates = [process.env["DSH_DESKTOP_DEV_PROFILE_DIR"], process.cwd()].filter(
38
+ (value): value is string => value !== undefined && value !== "",
39
+ );
40
+ for (const candidate of candidates) {
41
+ if (
42
+ await readFile(join(candidate, "package.json"), "utf8").then(
43
+ () => true,
44
+ () => false,
45
+ )
46
+ ) {
47
+ return candidate;
48
+ }
49
+ }
50
+ return process.cwd();
51
+ }
52
+
53
+ /** 装配根的清单:`<profileDir>/package.json`(pnpm 从这里解析到装配行与工作区的包)。 */
54
+ export async function profileManifestPath(): Promise<string> {
55
+ return join(await profileDir(), "package.json");
56
+ }
57
+
58
+ /**
59
+ * 解析根:依赖解析、watch、缓存的基准。
60
+ *
61
+ * dev 再向上找**工作区根**(`node_modules` 与 lockfile 在那儿);bundle 的装配根本身就带自己的
62
+ * `node_modules`,就是它。
63
+ */
64
+ export async function resolutionRoot(): Promise<string> {
65
+ resolutionRootPromise ??= (async () => {
66
+ const profile = await profileDir();
67
+ return isDevForm() ? ((await workspaceRootOf(profile)) ?? profile) : profile;
68
+ })();
69
+ return await resolutionRootPromise;
70
+ }
71
+
72
+ let resolutionRootPromise: Promise<string> | undefined;
73
+
74
+ /**
75
+ * 数据根(`$DSH_HOME`):会话、设置、附件、日志。
76
+ *
77
+ * dev 缺省是 `<装配根>/.dsh-store`(`dshdev` 会显式给值),bundle 由壳按 app 的 `dsh.desktop.dataDir`
78
+ * 解析后经环境传进来。
79
+ */
80
+ export async function dataDir(): Promise<string> {
81
+ return process.env["DSH_HOME"] ?? join(await profileDir(), ".dsh-store");
82
+ }
83
+
84
+ /** 解析根的清单:`<解析根>/package.json`(依赖解析的另一个基准)。 */
85
+ export async function resolutionManifestPath(): Promise<string> {
86
+ return join(await resolutionRoot(), "package.json");
87
+ }
88
+
89
+ /** pnpm 的虚拟 store:`<解析根>/node_modules/.pnpm`。 */
90
+ export async function storeDir(): Promise<string> {
91
+ return join(await resolutionRoot(), "node_modules", ".pnpm");
92
+ }
93
+
94
+ /** 这条链自己的缓存目录(联邦产物与聚合结果,与 vite 的 `.vite` 并列)。 */
95
+ export async function cacheDir(): Promise<string> {
96
+ return join(await resolutionRoot(), CLIENT_MODULES_CACHE);
97
+ }