@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.
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Client Modules",
4
+ "description": "Dev-form web layer: serves the page, the app entry and every client resource through an in-process vite dev server (the node-side counterpart of upstream client-modules, with the module table replaced by vite's resolver, prebundling and HMR)."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "客户端模块",
4
+ "description": "开发态的前端服务:页面、入口与全部客户端资源都由进程内的 vite dev server 提供(上游 client-modules 的 node 半,模块表那一套换成 vite 的解析、预打包与 HMR)。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@morlay/dsh-client-modules",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.6",
4
+ "description": "Dev-form web layer for dsh: serves the page, the app entry and every client resource through an in-process vite dev server (middleware mode) — the node-side counterpart of upstream @deepseek-ai/dsh-client-modules, with the module table replaced by vite's resolver, dependency prebundling and HMR.",
5
+ "keywords": [
6
+ "client",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "host",
10
+ "vite"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "morlay",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/morlay/dsh-plugin.git"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "app-boot",
21
+ "dist",
22
+ "locale/*.json"
23
+ ],
24
+ "type": "module",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.mts",
28
+ "default": "./dist/index.mjs"
29
+ },
30
+ "./package.json": "./package.json",
31
+ "./client": {
32
+ "types": "./dist/client.d.mts",
33
+ "import": "./dist/client.mjs",
34
+ "default": "./dist/client.mjs"
35
+ },
36
+ "./locale/*.json": "./locale/*.json"
37
+ },
38
+ "dependencies": {
39
+ "@deepseek-ai/dsh-client-ui-theme": "^0.2.1-alpha.5",
40
+ "@deepseek-ai/dsh-host-webserver": "^0.2.1-alpha.5",
41
+ "@morlay/dsh-cordis-assets": "0.2.6",
42
+ "@oxc-project/runtime": "^0.153.0",
43
+ "lightningcss": "^1.30.0",
44
+ "vite": "^8.3.1"
45
+ },
46
+ "devDependencies": {
47
+ "@deepseek-ai/cordis": "4.0.5-alpha.2",
48
+ "@local/devkit": "0.1.0",
49
+ "@types/node": "^26.5.0"
50
+ },
51
+ "peerDependencies": {
52
+ "@deepseek-ai/cordis": "^4.0.5-alpha.2",
53
+ "react": "^18.3.1",
54
+ "rolldown": "^1.2.14"
55
+ },
56
+ "engines": {
57
+ "node": ">=24.11.0"
58
+ },
59
+ "dsh": {
60
+ "client": {
61
+ "platform": "web",
62
+ "inject": []
63
+ }
64
+ },
65
+ "scripts": {
66
+ "build": "pnpm exec tsdown"
67
+ }
6
68
  }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * 产出的行为:联邦地址的形态,与"按请求触发、按包聚合"的缓存语义。
3
+ *
4
+ * 缓存那条最要紧:产物目录是**包级**的(出口各有自己的文件),而构建是按请求逐出口触发的——命中判据
5
+ * 少了"这次要的出口在不在",第二次请求的出口就会 404;构建之间不排队,并发请求会互相删掉对方的产物。
6
+ */
7
+ import { appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterAll, beforeAll, expect, test } from "vitest";
11
+ import { BUNDLER_REV, bundlePackage, clientModulesAlias, urlOf } from "../bundle.ts";
12
+
13
+ const repoRoot = process.cwd();
14
+
15
+ test("包级引用的出口按「是不是行」分:行 → client.js,普通 npm 包 → 根出口 index.js", () => {
16
+ const tables = {
17
+ versions: new Map([
18
+ ["@deepseek-ai/dsh-x", "1.2.3"],
19
+ ["react", "18.3.1"],
20
+ ]),
21
+ // 行由 loader 给(`dsh.client` + client 半);react 是普通 npm 包,不在里面。
22
+ clientRows: new Set(["@deepseek-ai/dsh-x"]),
23
+ };
24
+ expect(urlOf("@deepseek-ai/dsh-x", tables)).toBe(
25
+ `/@client-modules/@deepseek-ai+dsh-x@1.2.3/client.js?v=${BUNDLER_REV}`,
26
+ );
27
+ expect(urlOf("react", tables)).toBe(`/@client-modules/react@18.3.1/index.js?v=${BUNDLER_REV}`);
28
+ });
29
+
30
+ test("地址带 `?v=`(构建世代;与 vite 同名的参数)", () => {
31
+ const tables = { versions: new Map([["react", "18.3.1"]]) };
32
+ expect(urlOf("react", tables)).toBe(`/@client-modules/react@18.3.1/index.js?v=${BUNDLER_REV}`);
33
+ });
34
+
35
+ test("子路径引用保留子路径;扩展名与产物一致(没有扩展名的补 `.js`,有的原样)", () => {
36
+ const tables = {
37
+ versions: new Map([
38
+ ["@oxc-project/runtime", "0.153.0"],
39
+ ["katex", "0.16.47"],
40
+ ]),
41
+ };
42
+ // 产物名一律 `<出口名>.js`(`entryFileNames`),所以没扩展名的子路径要补上——不然一半 URL 带、一半不带。
43
+ expect(urlOf("@oxc-project/runtime/helpers/decorate", tables)).toBe(
44
+ `/@client-modules/@oxc-project+runtime@0.153.0/helpers/decorate.js?v=${BUNDLER_REV}`,
45
+ );
46
+ expect(urlOf("katex/dist/katex.min.css", tables)).toBe(
47
+ `/@client-modules/katex@0.16.47/dist/katex.min.css?v=${BUNDLER_REV}`,
48
+ );
49
+ });
50
+
51
+ let work: string;
52
+ let cacheRoot: string;
53
+ let packageRoot: string;
54
+
55
+ beforeAll(async () => {
56
+ work = await mkdtemp(join(tmpdir(), "dsh-bundle-"));
57
+ cacheRoot = join(work, ".cache");
58
+ packageRoot = join(work, "fixture");
59
+ await mkdir(join(packageRoot, "src"), { recursive: true });
60
+ await writeFile(
61
+ join(packageRoot, "src", "a.ts"),
62
+ [
63
+ "function Remote(): MethodDecorator { return () => undefined; }",
64
+ "export class Registry {",
65
+ " @Remote()",
66
+ " list(): string[] { return []; }",
67
+ "}",
68
+ "",
69
+ ].join("\n"),
70
+ "utf8",
71
+ );
72
+ // 这个包声明的运行期依赖(外置名单从这里来)。
73
+ await writeFile(
74
+ join(packageRoot, "package.json"),
75
+ `${JSON.stringify({ name: "fixture", dependencies: { zod: "^4.0.0" } })}\n`,
76
+ "utf8",
77
+ );
78
+ // 与 profile 同形:编译设置**在解析根里**(打包形态是 profile,dev 是工作区)。
79
+ await writeFile(
80
+ join(work, "tsconfig.json"),
81
+ `${JSON.stringify({ compilerOptions: { target: "esnext", experimentalDecorators: true } })}\n`,
82
+ "utf8",
83
+ );
84
+ await writeFile(join(packageRoot, "src", "b.ts"), "export const b = 2;\n", "utf8");
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await rm(work, { recursive: true, force: true });
89
+ });
90
+
91
+ function options(exportName: string): Parameters<typeof bundlePackage>[0] {
92
+ return {
93
+ cacheRoot,
94
+ repoRoot: work,
95
+ alias: clientModulesAlias({
96
+ repoRoot,
97
+ nodeStub: join(repoRoot, "packages/client/dsh-client-modules/app-boot/node-stub.cjs"),
98
+ }),
99
+ name: "fixture",
100
+ version: "1.0.0",
101
+ input: { [exportName]: join(packageRoot, "src", `${exportName}.ts`) },
102
+ watch: [join(packageRoot, "src")],
103
+ };
104
+ }
105
+
106
+ test("按出口命中:先请求 a、再请求 b,b 不该被当成命中,且 a 的产物要留下", async () => {
107
+ const first = await bundlePackage(options("a"));
108
+ expect([...(first?.files.keys() ?? [])]).toEqual(["a"]);
109
+ const second = await bundlePackage(options("b"));
110
+ expect([...(second?.files.keys() ?? [])].sort()).toEqual(["a", "b"]);
111
+ // 增量合并:a 的产物还在磁盘上(没被这次构建清掉)。
112
+ const aFile = join(cacheRoot, first?.files.get("a") ?? "");
113
+ await expect(stat(aFile)).resolves.toBeDefined();
114
+ });
115
+
116
+ test("已产出的出口再次请求:命中缓存,不重打(产物的 mtime 不变)", async () => {
117
+ const target = join(cacheRoot, "fixture@1.0.0", "a.js");
118
+ const before = await stat(target);
119
+ await new Promise((resolve) => setTimeout(resolve, 20));
120
+ await bundlePackage(options("a"));
121
+ const after = await stat(target);
122
+ expect(after.mtimeMs).toBe(before.mtimeMs);
123
+ });
124
+
125
+ test("地址与产物内容无关:构建前后是同一个 URL(内容变了靠协商缓存,URL 不裂成两个)", async () => {
126
+ const tables = { versions: new Map([["fixture", "1.0.0"]]) };
127
+ const before = urlOf("fixture", tables);
128
+ await bundlePackage(options("a"));
129
+ expect(urlOf("fixture", tables)).toBe(before);
130
+ });
131
+
132
+ test("源码变了:指纹变、重打(且旧产物作废)", async () => {
133
+ const first = await bundlePackage(options("a"));
134
+ await appendFile(join(packageRoot, "src", "a.ts"), "export const a2 = 3;\n", "utf8");
135
+ const second = await bundlePackage(options("a"));
136
+ expect(second?.rev).not.toBe(first?.rev);
137
+ });
138
+
139
+ test("装饰器在产物里必须已被转译,且**不依赖进程 cwd**", async () => {
140
+ // 这条是"不同工作目录要有一致行为"的守卫:vite build(rolldown 的 oxc)默认按 **cwd** 找 tsconfig,
141
+ // 而这条链在打包形态下跑在宿主进程里(cwd 是宿主的,不是解析根)——少了这条,上游用 cordis 装饰器的包
142
+ // 就会在产物里原样留着 `@Remote(...)`,浏览器报 `SyntaxError`、入口整条不执行。
143
+ const elsewhere = await mkdtemp(join(tmpdir(), "dsh-bundle-cwd-"));
144
+ const before = process.cwd();
145
+ process.chdir(elsewhere);
146
+ try {
147
+ const bundled = await bundlePackage(options("a"));
148
+ const code = await readFile(join(cacheRoot, bundled?.files.get("a") ?? ""), "utf8");
149
+ expect(code).not.toMatch(/@Remote/u);
150
+ expect(code).toMatch(/__decorate|_ts_decorate|decorate/u);
151
+ } finally {
152
+ process.chdir(before);
153
+ await rm(elsewhere, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test("包住在没有 tsconfig 祖先的地方时,`resolve.tsconfigFilename` 也能让它转", async () => {
158
+ // 外置/官方的包就住在 `node_modules/.pnpm/…` 里——向上找不到任何 tsconfig。这条守着那个开关:
159
+ // 只给 `transform.decorator`(实测 vite 不带进 rolldown)不行,`resolve.tsconfigFilename` 才行。
160
+ const work2 = await mkdtemp(join(tmpdir(), "dsh-bundle-tsfn-"));
161
+ const pkg2 = join(work2, "node_modules", "fixture");
162
+ await mkdir(join(pkg2, "src"), { recursive: true });
163
+ await writeFile(
164
+ join(pkg2, "src", "a.ts"),
165
+ [
166
+ "function Remote(): MethodDecorator { return () => undefined; }",
167
+ "export class Registry {",
168
+ " @Remote()",
169
+ " list(): string[] { return []; }",
170
+ "}",
171
+ "",
172
+ ].join("\n"),
173
+ "utf8",
174
+ );
175
+ // tsconfig 放在**解析根之外**(那个包在 `work2/node_modules/…`,祖先链里没有 tsconfig),由
176
+ // `resolve.tsconfigFilename` 显式指过去——这正是外置/官方包在产物里的处境。
177
+ const elsewhere = join(work2, "..", `dsh-tsconfig-${String(Date.now())}`, "tsconfig.json");
178
+ await mkdir(join(elsewhere, ".."), { recursive: true });
179
+ await writeFile(
180
+ elsewhere,
181
+ `${JSON.stringify({ compilerOptions: { target: "esnext", experimentalDecorators: true } })}\n`,
182
+ "utf8",
183
+ );
184
+ try {
185
+ const bundled = await bundlePackage({
186
+ cacheRoot: join(work2, ".cache"),
187
+ repoRoot: work2,
188
+ alias: [],
189
+ name: "fixture",
190
+ version: "1.0.0",
191
+ input: { a: join(pkg2, "src", "a.ts") },
192
+ watch: [join(pkg2, "src")],
193
+ tsconfig: elsewhere,
194
+ });
195
+ const code = await readFile(join(join(work2, ".cache"), bundled?.files.get("a") ?? ""), "utf8");
196
+ // 债已销:这条链改成 **rolldown 直出**(不再经 `vite build`),`resolve.tsconfigFilename` 才落到
197
+ // rolldown 手里——外置/官方的包住在 `node_modules/.pnpm/…`、祖先链里没有 tsconfig,全靠这里指路。
198
+ expect(code).not.toMatch(/@Remote/u);
199
+ } finally {
200
+ await rm(work2, { recursive: true, force: true });
201
+ await rm(join(elsewhere, ".."), { recursive: true, force: true });
202
+ }
203
+ });
204
+
205
+ test("清单里解析不到的依赖不算可外置候选,也不该让预编译失败", async () => {
206
+ // 真实里踩到的形状:`zustand` 的 `peerDependencies` 里有 `@types/react`(optional)——类型包不在运行时闭包
207
+ // 里,于是解析它必然失败。它是**可选** peer、又解析不到,两条都不该让它进候选;若把异常抛出去,死的是
208
+ // 整个包的预编译(表现为页面上"很多资源加载不了")。
209
+ const root = join(work, "absent");
210
+ await mkdir(join(root, "src"), { recursive: true });
211
+ await writeFile(join(root, "src", "index.ts"), "export const x = 1;\n", "utf8");
212
+ await writeFile(
213
+ join(root, "package.json"),
214
+ `${JSON.stringify({
215
+ name: "absent",
216
+ dependencies: { "@types/absent-types": ">=1" },
217
+ peerDependencies: { "@types/absent-types": ">=1", "@types/absent-optional": ">=1" },
218
+ peerDependenciesMeta: { "@types/absent-optional": { optional: true } },
219
+ })}\n`,
220
+ "utf8",
221
+ );
222
+
223
+ const built = await bundlePackage({
224
+ cacheRoot,
225
+ repoRoot: work,
226
+ alias: [],
227
+ name: "absent",
228
+ version: "1.0.0",
229
+ input: { index: join(root, "src", "index.ts") },
230
+ watch: [join(root, "src")],
231
+ // **本包视角**的清单:现场预编译时由调用方给(外置名单与版本表从它来)——不给就走不到那条路径。
232
+ manifestOf: join(root, "package.json"),
233
+ });
234
+ expect([...(built?.files.keys() ?? [])]).toEqual(["index"]);
235
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * 解析面的行为:`exports` 的三种形状、子路径的物理回退、以及"包名没有 `.` 出口"的包怎么找包根。
3
+ *
4
+ * 这些都是**dev 形态的联邦寻址**直接依赖的判断——它们错了,浏览器侧表现为一串难归因的 404
5
+ * 或"模块没有导出 X"。
6
+ */
7
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
11
+ import { exportTargetOf, fileInPackage, packageRootOf } from "../resolve.ts";
12
+
13
+ /** 造一个包(清单 + 文件),用来测"清单说了什么"这一类判断。 */
14
+ async function makePackage(
15
+ root: string,
16
+ manifest: unknown,
17
+ files: Record<string, string>,
18
+ ): Promise<void> {
19
+ await mkdir(root, { recursive: true });
20
+ await writeFile(join(root, "package.json"), JSON.stringify(manifest, null, 2), "utf8");
21
+ for (const [path, content] of Object.entries(files)) {
22
+ const file = join(root, path);
23
+ await mkdir(dirname(file), { recursive: true });
24
+ await writeFile(file, content, "utf8");
25
+ }
26
+ }
27
+
28
+ describe("exportTargetOf", () => {
29
+ let work: string;
30
+
31
+ beforeAll(async () => {
32
+ work = await mkdtemp(join(tmpdir(), "dsh-resolve-"));
33
+ });
34
+
35
+ afterAll(async () => {
36
+ await rm(work, { recursive: true, force: true });
37
+ });
38
+
39
+ test("条件**数组**取 `import` 那支(@oxc-project/runtime 的每个出口都是这形状)", async () => {
40
+ const root = join(work, "array-conditions");
41
+ await makePackage(
42
+ root,
43
+ {
44
+ name: "array-conditions",
45
+ version: "1.0.0",
46
+ exports: {
47
+ "./helpers/decorate": [
48
+ {
49
+ node: "./src/helpers/decorate.js",
50
+ import: "./src/helpers/esm/decorate.js",
51
+ default: "./src/helpers/decorate.js",
52
+ },
53
+ "./src/helpers/decorate.js",
54
+ ],
55
+ },
56
+ },
57
+ {
58
+ "src/helpers/esm/decorate.js": "export default 1;\n",
59
+ "src/helpers/decorate.js": "module.exports = 1;\n",
60
+ },
61
+ );
62
+ await expect(exportTargetOf(root, "helpers/decorate")).resolves.toBe(
63
+ join(root, "src/helpers/esm/decorate.js"),
64
+ );
65
+ });
66
+
67
+ test("没有 `exports` 时退回 `module` → `main`", async () => {
68
+ const root = join(work, "legacy");
69
+ await makePackage(
70
+ root,
71
+ { name: "legacy", version: "1.0.0", main: "index.js", module: "esm/index.mjs" },
72
+ { "esm/index.mjs": "export {};\n" },
73
+ );
74
+ await expect(exportTargetOf(root, undefined)).resolves.toBe(join(root, "esm/index.mjs"));
75
+ });
76
+
77
+ test("没有 `.` 出口时答不上来(交给物理回退)", async () => {
78
+ const root = join(work, "no-root-export");
79
+ await makePackage(
80
+ root,
81
+ { name: "no-root-export", version: "1.0.0", exports: { "./posix": "./posix.js" } },
82
+ { "posix.js": "module.exports = 1;\n" },
83
+ );
84
+ await expect(exportTargetOf(root, undefined)).resolves.toBeUndefined();
85
+ });
86
+ });
87
+
88
+ describe("fileInPackage", () => {
89
+ let work: string;
90
+
91
+ beforeAll(async () => {
92
+ work = await mkdtemp(join(tmpdir(), "dsh-file-"));
93
+ });
94
+
95
+ afterAll(async () => {
96
+ await rm(work, { recursive: true, force: true });
97
+ });
98
+
99
+ test("清单没覆盖的子路径按物理路径找(`picomatch/posix` 就是 `posix.js`)", async () => {
100
+ const root = join(work, "no-exports");
101
+ await makePackage(
102
+ root,
103
+ { name: "no-exports", version: "1.0.0", main: "index.js" },
104
+ {
105
+ "index.js": "module.exports = 1;\n",
106
+ "posix.js": "module.exports = 2;\n",
107
+ },
108
+ );
109
+ await expect(fileInPackage(root, "posix")).resolves.toBe(join(root, "posix.js"));
110
+ });
111
+
112
+ test("越出包根一律拒绝", async () => {
113
+ const root = join(work, "bounded");
114
+ await makePackage(root, { name: "bounded", version: "1.0.0" }, { "index.js": "export {};\n" });
115
+ await expect(fileInPackage(root, "../escape")).resolves.toBeUndefined();
116
+ });
117
+ });
118
+
119
+ test("packageRootOf 认得**没有 `.` 出口**的包(@oxc-project/runtime 只有 ./helpers/*)", async () => {
120
+ // 从本包自己的清单出发解析:它是这条链声明的运行期依赖(rolldown 注入装饰器 helper 用的那个包)。
121
+ const found = await packageRootOf(
122
+ join(process.cwd(), "packages", "client", "dsh-client-modules", "package.json"),
123
+ "@oxc-project/runtime",
124
+ );
125
+ expect(found?.root).toContain("@oxc-project/runtime");
126
+ expect(found?.version).not.toBe("0");
127
+ });