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

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,240 @@
1
+ /**
2
+ * `client-modules` 的 vite 插件形态:开发态的客户端资源全部交给 vite(依赖预打包、CJS→ESM、具名导出、
3
+ * HMR 都是它现成的能力),我们只提供"哪些行、从哪个源文件进"这一层。
4
+ *
5
+ * 为什么不自己写这条链:`optimizeDeps` 处理的正是我们手搓过的那些事(CJS 转 ESM、具名导出补全、外置
6
+ * `require` 转 import、共享 chunk 保证实例唯一、内容 hash 缓存),而 HMR 更是要自己实现一大套。插件只做
7
+ * 三件事:把行清单变成虚拟入口、把包的说明符指到源码、把入口注入 HTML。
8
+ */
9
+ import { readFile } from "node:fs/promises";
10
+ import { createRequire } from "node:module";
11
+ import { dirname, join, relative, resolve as resolvePath, sep } from "node:path";
12
+ import type { Plugin, ViteDevServer } from "vite";
13
+ import { devExcluded, isAggregated } from "./config.ts";
14
+ import { profileDir } from "./dirs.ts";
15
+ import { CLIENT_MODULES_PREFIX } from "./bundle.ts";
16
+ import { clientHalfOf, exportTargetOf, packageNameOf, packageRootOf } from "./resolve.ts";
17
+
18
+ /** 行清单的虚拟模块 id(`app-boot/index.ts` 里 `import rows from "virtual:dsh-client-rows"`)。 */
19
+ export const ROWS_VIRTUAL = "dsh-client-rows";
20
+
21
+ const RESOLVED_ROWS = `\0${ROWS_VIRTUAL}`;
22
+
23
+ export interface ClientRowsPluginOptions {
24
+ /** 仓库根(vite 的 root)。 */
25
+ repoRoot: string;
26
+ /** 行清单:包名 → client 半的**源码**入口(`src/client/index.ts` 那种)。 */
27
+ rows: () => Promise<Map<string, string>>;
28
+ /** 公共包(被多处引用,预打包时共享 chunk 才能保证实例唯一):包名 → 源码入口。 */
29
+ shared?: () => Promise<Map<string, string>>;
30
+ /**
31
+ * 把**对外形态**的 `/plugins/<pkg>/<version>/<export>` 解析成**源码文件**。
32
+ *
33
+ * 这一条是必需的:我们自己生成的装配脚本(以及各行的产物)里用的是对外 URL,而 vite 不认这种形态。
34
+ * 由它把 URL 换成 vite 能继续处理的文件路径——浏览器请求那条路由时也是同一套解析。
35
+ */
36
+ resolvePluginUrl?: (url: string) => Promise<string | undefined>;
37
+ /**
38
+ * 裸说明符 → **联邦地址**(`@client-modules/<pkg>@<ver>/<export>`)。三方与"非开发模块"都走它:
39
+ * 它们由 `dsh-client-modules` 自己的路由按包预编译,vite 只该把这个地址当外部留着。
40
+ */
41
+ depsUrl?: (specifier: string, importer?: string) => Promise<string | undefined>;
42
+ /** 每次 dev server 建好时回调(含 vite 自己 `restart()` 后的新实例)——调用方据此切换当前实例。 */
43
+ onServer?: (server: ViteDevServer) => void;
44
+ }
45
+
46
+ /**
47
+ * 依赖预打包的入口名。vite 的 `optimizeDeps.entries` 期望"文件路径",而我们手上是包名与源码路径的
48
+ * 映射,所以用一个小技巧:把入口写成**虚拟模块**再让 vite 把它当 discovery 的种子——见 `configResolved`
49
+ * 里的 `optimizeDeps.include`。
50
+ */
51
+ /** 从某个文件向上找包根(有 `package.json` 的那一层)。 */
52
+ async function packageRootOfFile(file: string): Promise<string | undefined> {
53
+ let directory = dirname(file);
54
+ for (;;) {
55
+ if (
56
+ await readFile(join(directory, "package.json"), "utf8").then(
57
+ () => true,
58
+ () => false,
59
+ )
60
+ )
61
+ return directory;
62
+ const parent = dirname(directory);
63
+ if (parent === directory) return undefined;
64
+ directory = parent;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * 「有 client 先用 client」:给定一条**从 client 图里发起**的相对引用,返回它的 client 变体(没有则 undefined)。
70
+ *
71
+ * 上游的包是同构的:`src/foo.ts` 与 `src/client/foo.ts` 成对,`src/client/index.ts` 会值导入 `../foo.ts`
72
+ * 这类共享文件。若该共享文件在 client 侧有对应实现,就用它——这与 node 的 `browser` 字段、打包器的
73
+ * `browser` 条件是同一件事,只是上游的 `exports` 没有为每个子路径声明 browser 条件。
74
+ *
75
+ * @param source - 引用说明符(相对路径)。
76
+ * @param importer - 发起引用的文件(必在某个包的 `src/client/` 下)。
77
+ * @returns client 变体的绝对路径,或 undefined(没有变体、或引用跨出了本包)。
78
+ */
79
+ async function clientVariantOf(source: string, importer: string): Promise<string | undefined> {
80
+ const marker = `${sep}src${sep}client${sep}`;
81
+ const at = importer.indexOf(marker);
82
+ if (at < 0) return undefined;
83
+ // `<pkg>/src`
84
+ const packageSrc = importer.slice(0, at + `${sep}src`.length);
85
+ const target = resolvePath(dirname(importer), source);
86
+ if (target.includes(marker)) return undefined;
87
+ const relativeFromSrc = relative(packageSrc, target);
88
+ if (relativeFromSrc.startsWith("..")) return undefined;
89
+ const variant = join(packageSrc, "client", relativeFromSrc);
90
+ return (await readFile(variant, "utf8").then(
91
+ () => true,
92
+ () => false,
93
+ ))
94
+ ? variant
95
+ : undefined;
96
+ }
97
+
98
+ export function dshClientRowsPlugin(options: ClientRowsPluginOptions): Plugin {
99
+ let excludedCache: RegExp[] | undefined;
100
+ const excludedOf = async (): Promise<RegExp[]> => {
101
+ if (excludedCache === undefined) excludedCache = await devExcluded(await profileDir());
102
+ return excludedCache;
103
+ };
104
+ const requireFromRepo = createRequire(join(options.repoRoot, "package.json"));
105
+
106
+ return {
107
+ name: "dsh-client-rows",
108
+ // **必须在 `vite:resolve` 之前**:vite 的解析器会先查 `optimizeDeps` 的缓存,命中就把裸说明符改写成
109
+ // `node_modules/.vite/deps/…`(那条链一旦成立,同一个包就有两份实例——react 的 `Invalid hook call`
110
+ // 就是这么来的)。我们这条链要自己说了算:裸说明符归联邦地址、行包归源码。
111
+ enforce: "pre",
112
+ config() {
113
+ return {
114
+ resolve: {
115
+ // 让 vite 认我们的条件顺序(与打包链一致)。
116
+ conditions: ["browser", "import", "module", "default"],
117
+ },
118
+ };
119
+ },
120
+ // 包名 → 源码入口:上游包经 exports 指到 `src`,工作区包本来就是源码;这里把两侧都显式接上,
121
+ // 免得 vite 走 `main`/`dist`(那些在 dev 形态下要么不存在、要么是构建产物)。
122
+ async resolveId(source, importer) {
123
+ if (source === ROWS_VIRTUAL) return RESOLVED_ROWS;
124
+ // 联邦地址(`@client-modules/<pkg>@<version>/<export>`):它由我们自己的路由服务(聚合产物 /
125
+ // 三方预打包),vite 只该把它当外部地址留在 import 里,不要试图去解析。
126
+ // 联邦地址:由我们自己那条链服务(按请求预编译 + 落盘缓存),vite 只把它当**外部**留着。
127
+ // `external: "absolute"` 而不是 `true`:这是以 `/` 开头的地址,vite 的预转换会把它当仓库文件去加载
128
+ // (日志里一串 `Failed to load url /@client-modules/…` 就是它)。
129
+ if (source.startsWith(`${CLIENT_MODULES_PREFIX}/`))
130
+ return { id: source, external: "absolute" };
131
+ // 聚合产物的 URL:它由 `dsh-client-modules` 自己的路由服务(按包预编译 + 落盘缓存),
132
+ // vite 只该把它当外部地址保留在 import 里,不要试图去读文件。
133
+ // 对外形态的 URL → 源码文件(vite 只认文件路径与它自己的虚拟 id)。
134
+ // `@/…` = **仓库内的本地文件**(页面与源码都用它引用仓库里别处的文件):交给 alias,绝不外置。
135
+ if (source.startsWith("@/")) return null;
136
+ if (source.startsWith("/") && options.resolvePluginUrl !== undefined) {
137
+ const file = await options.resolvePluginUrl(source.split("?")[0] ?? source);
138
+ if (file !== undefined) return file;
139
+ }
140
+ if (source.startsWith("\0")) return null;
141
+ // **「有 client 先用 client」**:与 node 的 `browser` 字段同构的补偿。上游的包是**同构**的——
142
+ // `src/foo.ts`(宿主半)与 `src/client/foo.ts`(客户端半)成对出现,而 `src/client/index.ts` 会
143
+ // 值导入 `../foo.ts` 这类共享文件;某些共享文件再往下引就会牵出宿主半(`http-bridge.ts` 用
144
+ // `node:stream`)。凡是**从 client 图里发起的引用**,只要存在 client 变体就取变体。
145
+ if (importer !== undefined && importer.includes(`${sep}src${sep}client${sep}`)) {
146
+ const variant = await clientVariantOf(source, importer);
147
+ if (variant !== undefined) return variant;
148
+ }
149
+ if (importer === undefined || source.startsWith(".") || source.startsWith("/")) return null;
150
+ // **仓库内的包**:默认一律落到源码文件——它们的 bare 包名一旦交给依赖优化器就会被预打包,于是同一个
151
+ // 包既有预打包版又有源码版(两份实例,表现为「同 namespace 的 locale 已注册」)。
152
+ //
153
+ // 例外是 `dsh.dev.exclude` 点名的**非开发模块**:它们本来就该被预打包(`optimizeDeps.include` +
154
+ // alias 指的也是同一个入口),所以这里**放行**,交给优化器。
155
+ // 解析基准:虚拟模块(`\0…`)**不是**文件系统路径——`createRequire` 拿到它会直接抛
156
+ // (行清单里的包名 import 就走在虚拟模块里)。以 `/` 开头的是仓库根相对路径。
157
+ const fromFile =
158
+ importer === undefined || importer.startsWith("\0")
159
+ ? undefined
160
+ : importer.startsWith("/")
161
+ ? join(options.repoRoot, importer)
162
+ : importer;
163
+ if (fromFile !== undefined) {
164
+ const bare = packageNameOf(source);
165
+ if (bare !== "") {
166
+ const pkg = await packageRootOf(fromFile, bare);
167
+ const inRepo = pkg !== undefined && !pkg.root.includes("node_modules");
168
+ if (inRepo && !isAggregated(bare, await excludedOf())) {
169
+ const subpath = source === bare ? undefined : source.slice(bare.length + 1);
170
+ const half = await clientHalfOf(pkg.root, subpath);
171
+ if (half !== undefined) return half;
172
+ }
173
+ }
174
+ }
175
+ // **非开发模块与三方**:都交给联邦链(按包预编译 + 落盘缓存),vite 只把地址当外部留着。
176
+ // 这一条是必需的:若放给 vite(`optimizeDeps`),同一份依赖会多出第二条链——浏览器里同时出现
177
+ // `node_modules/.vite/deps/react.js` 与我们的 `@client-modules/react@…/index.js`,两个 react 实例,
178
+ // hook 分发立刻报 `Invalid hook call`。
179
+ if (options.depsUrl !== undefined) {
180
+ const url = await options.depsUrl(source, importer);
181
+ if (url !== undefined) return { id: url, external: true };
182
+ }
183
+ // 行包名 → 它的 **client 半**源文件(行清单已经解析好;vite 只负责把它当普通模块编译与缓存)。
184
+ const rows = await options.rows();
185
+ const shared = (await options.shared?.()) ?? new Map<string, string>();
186
+ const known = rows.get(source) ?? shared.get(source);
187
+ // 值是**源码路径** → 直接用它(开发模块逐文件走 vite);值是**包名本身**("非开发模块"的行)→
188
+ // 不在这里作答,继续往下走 `depsUrl`,让它按同一条规则算地址。
189
+ if (known !== undefined && known !== source) return known;
190
+ // **行包 + 子路径**(`@deepseek-ai/dsh-client-ui-theme/brand-font.css` 这种):包在 vendor 下、root 的
191
+ // node_modules 里没有,vite 自己解析不到;用行的包根 + 清单的 `exports` 落文件(标准 exports 语义)。
192
+ const slash = source.startsWith("@")
193
+ ? source.indexOf("/", source.indexOf("/") + 1)
194
+ : source.indexOf("/");
195
+ if (slash > 0) {
196
+ const pkgName = source.slice(0, slash);
197
+ const subpath = source.slice(slash + 1);
198
+ const rowPath = rows.get(pkgName) ?? shared.get(pkgName);
199
+ if (rowPath !== undefined) {
200
+ const rowRoot = await packageRootOfFile(rowPath);
201
+ const target = rowRoot === undefined ? undefined : await exportTargetOf(rowRoot, subpath);
202
+ if (target !== undefined) return target;
203
+ }
204
+ }
205
+ // 其余交给 vite 自己按 exports 解析。
206
+ void requireFromRepo;
207
+ return null;
208
+ },
209
+ // 应用入口:把我们生成的装配脚本作为虚拟模块交给 vite 服务(这样它的 import、HMR、依赖图都由
210
+ // vite 管;我们不必自己拦一条路由再手写 HTTP)。
211
+ async load(id) {
212
+ if (id !== RESOLVED_ROWS) return null;
213
+ const rows = await options.rows();
214
+ const shared = (await options.shared?.()) ?? new Map<string, string>();
215
+ // import **源码文件路径**(不是包名):包名会让依赖优化器把它当"依赖"预打包,于是同一个包出现
216
+ // 预打包版与源码版两份实例(症状是「同 namespace 的 locale 已注册」与跨实例的私有字段报错)。
217
+ // 文件路径只走源码面,vite 照常转译它。
218
+ const imports: string[] = [];
219
+ const entries: string[] = [];
220
+ let index = 0;
221
+ for (const [pkg, file] of [...shared, ...rows]) {
222
+ const alias = `m${String(index)}`;
223
+ // **行的入口**两种形态:源码文件(`@/…`,vite 逐文件转译、HMR 照旧)与**聚合行**(`dsh.dev.exclude`
224
+ // 点名的那些——`rows` 把它们的 `clientPath` 写成了包名本身)。后者要在这里**换成联邦地址**:
225
+ // 虚拟模块里写**包名**会被 `vite:import-analysis` 当成依赖去解析(importer 记成根 `package.json`),
226
+ // 落到工作区解析面上就是 `Cannot find package '@deepseek-ai/dsh-typert-registry' …`——上游 workspace
227
+ // 内部包只存在于 vendor 与闭包里。写地址则只是"一个外部 URL",vite 不会去碰它。
228
+ const specifier = file === pkg ? ((await options.depsUrl?.(pkg)) ?? file) : file;
229
+ imports.push(`import * as ${alias} from ${JSON.stringify(specifier)};`);
230
+ entries.push(` { id: ${JSON.stringify(pkg)}, module: ${alias} },`);
231
+ index += 1;
232
+ }
233
+ // 具名导出 `default` 供 `app-boot/index.ts` 消费:一份可遍历的行清单。
234
+ return [...imports, "", "export default [", ...entries, "];", ""].join("\n");
235
+ },
236
+ configureServer(devServer) {
237
+ options.onServer?.(devServer);
238
+ },
239
+ };
240
+ }