@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/src/bundle.ts ADDED
@@ -0,0 +1,708 @@
1
+ /**
2
+ * 客户端资源的**联邦式**产出:一个包一份产物,三方的与我们的**没有本质区别**。
3
+ *
4
+ * vite 8 底下就是 rolldown,转换规则、插件钩子、CSS 与资产的处理都在同一套配置里——所以这里复用
5
+ * dev 那份配置(`cssInlinePlugins` / `clientAssetPlugin` / alias / define),只把它跑在 build 模式上:
6
+ * **一次构建、产物按出口分开**(多 entry + 共享 chunk)、落盘缓存、按输入指纹失效。
7
+ *
8
+ * 差别只在"入口从哪儿来":我们的包按 `exports` 找它的 client 半,三方按 node 的解析找它的入口。
9
+ * 两者产出的地址同构(`@client-modules/<pkg>@<version>/<file>`),所以运行时按 URL 取即可。
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
13
+ import { createRequire } from "node:module";
14
+ import { findPackageJSON } from "node:module";
15
+ import { dirname, join, relative, sep } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { clientAssetPlugin, NODE_BUILTIN } from "@morlay/dsh-cordis-assets/assets";
18
+ import { cssInlinePlugins } from "@morlay/dsh-cordis-assets/styles";
19
+ import { rolldown, type Plugin } from "rolldown";
20
+ import { esmExternalRequirePlugin, type PluginOption } from "vite";
21
+ import { encodePackageName, entryFileNameOf, packageNameOf } from "./resolve.ts";
22
+
23
+ /** 裸包名 → `^名(/|$)`:既覆盖子路径引用,也不会把 `node:fs`、`./x` 误判成包。 */
24
+ function barePackageRegExp(name: string): RegExp {
25
+ return new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}(?:/|$)`, "u");
26
+ }
27
+
28
+ /** 统一前缀:三方与我们的包共用同一个命名空间。 */
29
+ export const CLIENT_MODULES_PREFIX = "/@client-modules";
30
+
31
+ /** 统一缓存目录(与 vite 的 `.vite` 并列,跨进程共用)。 */
32
+ export const CLIENT_MODULES_CACHE = join("node_modules", ".dsh-client-modules");
33
+
34
+ /**
35
+ * 客户端资源链的**解析约定**(dev 的 vite server 与联邦构建各持一份,规则相同)。
36
+ *
37
+ * - `@/…` = 仓库根的文件(源码里引用仓库内文件都用这个前缀,不写死绝对路径);
38
+ * - `node:*` = 求值安全的空替身文件。**整串匹配**:`find: /^node:/` 是前缀替换,会把 `node:fs/promises`
39
+ * 拼成 `<替身路径>fs/promises`(构建期报 UNLOADABLE_DEPENDENCY)。
40
+ *
41
+ * 替身的**形态**两条链不同(dev 是 ESM、构建要 CJS,见 `app-boot/node-stub.*` 里各自的说明),
42
+ * 所以由调用方给路径。
43
+ */
44
+ export function clientModulesAlias(options: {
45
+ repoRoot: string;
46
+ nodeStub: string;
47
+ }): { find: RegExp; replacement: string }[] {
48
+ return [
49
+ { find: /^@\//u, replacement: `${options.repoRoot}/` },
50
+ // **裸名的内置也要接管**:产物里出现的是 `require("zlib")`、`import { Readable } from "stream"`
51
+ // 这类写法(没有 `node:` 前缀),只匹配前缀会让它们漏到浏览器里当包解析——那边报的是一串
52
+ // `@client-modules/zlib@0.0.0/index.js` 404。名单与联邦构建用的是同一份。
53
+ { find: NODE_BUILTIN, replacement: options.nodeStub },
54
+ ];
55
+ }
56
+
57
+ /**
58
+ * 构建器版本:**改了就 bump**。
59
+ *
60
+ * 缓存指纹算的是输入源码,而产物的形态还取决于这里的解析/改写规则(联邦地址怎么归一等)——
61
+ * 规则变了、源码没变时,旧产物必须作废,否则会继续复用带着老 URL 的字节。
62
+ */
63
+ /** 构建器**规则**版本:改解析 / 改写规则就 bump(见 {@link BUNDLER_REV})。 */
64
+ const BUNDLER_RULES = "22";
65
+
66
+ /**
67
+ * 地址里的 `?v=`(与 vite 同一个写法):构建器**规则指纹**(`BUNDLER_RULES` 的哈希,形如 vite 的 `?v=`)。
68
+ *
69
+ * 它只随"这条链的规则"变化——一次会话内所有地址一致(同一个包只有一个 URL,多一个 URL 就多一份模块
70
+ * 实例)。内容变了**不换 URL**,靠 `cache-control: no-cache` + `ETag` 协商(vite dev 对它自己的模块
71
+ * 也是这套)。
72
+ */
73
+ export const BUNDLER_REV: string = createHash("sha1")
74
+ .update(`client-modules:rules:${BUNDLER_RULES}`)
75
+ .digest("hex")
76
+ .slice(0, 8);
77
+
78
+ /** 包 → 它在浏览器里的地址(联邦式)。 */
79
+ export function depsUrlOf(specifier: string, version: string, file = "index.js"): string {
80
+ // 归一到**包名**:说明符可能带子路径(`@deepseek-ai/dsh-x/client`、`react/jsx-dev-runtime`)。
81
+ const name = packageNameOf(specifier) || specifier;
82
+ return `${CLIENT_MODULES_PREFIX}/${encodePackageName(name)}@${version}/${file}`;
83
+ }
84
+
85
+ /** 一次产出的输入。 */
86
+ export interface BundleOptions {
87
+ /** 诊断输出(`[bundle-*]`):由调用方接到自己的 logger(cordis 的 `ctx.logger`);不注入即静默。 */
88
+ readonly log?: (message: string) => void;
89
+ /** 缓存根({@link CLIENT_MODULES_CACHE})。 */
90
+ cacheRoot: string;
91
+ /** 仓库根(vite 的 root)。 */
92
+ repoRoot: string;
93
+ /** 与 dev 共用的解析配置(alias)。 */
94
+ alias: readonly { find: RegExp | string; replacement: string }[];
95
+ /** 包名。 */
96
+ name: string;
97
+ /** 包版本(进缓存键与地址)。 */
98
+ version: string;
99
+ /** 入口名 → 源码文件(我们的包:出口名;三方:`index`)。 */
100
+ input: Record<string, string>;
101
+ /** 指纹的输入文件集(我们的包给 `src`;三方给它的入口)。 */
102
+ watch: readonly string[];
103
+ /**
104
+ * **本包**的清单(`package.json` 的绝对路径)。
105
+ *
106
+ * 外置说明符要写成带版本的联邦地址,而版本必须**从引它的那个包**解析——不同包的依赖树不同(peer 分叉时
107
+ * 同一个名字可以是不同版本),全局一张表必然猜错。回调是同步的,所以先对这个包的依赖面预建一张小表。
108
+ */
109
+ manifestOf?: string;
110
+ /** **行**的包名集合(loader 给的 `dsh.client` + client 半):决定包级引用的出口名。 */
111
+ clientRows?: ReadonlySet<string>;
112
+ /** 编译设置(rolldown 的 `tsconfig`):解析根那份 tsconfig 的路径;缺省时交给 rolldown 就近找。 */
113
+ tsconfig?: string;
114
+ /**
115
+ * 包名 → 外置它时该指的**出口**(`client` / `index`)。
116
+ *
117
+ * `client` 不是什么特殊身份,就是清单里的一个普通 export:有 `./client` 的包外置成 `client.js`,
118
+ * 没有的就是 `index.js`(= 根出口)。表是预建的,因为 `paths` 回调必须同步。
119
+ */
120
+ }
121
+
122
+ /** 产物:文件相对路径。 */
123
+ export interface Bundled {
124
+ /** 输入指纹。 */
125
+ rev: string;
126
+ /** 出口名 → 相对缓存根的路径。 */
127
+ files: Map<string, string>;
128
+ }
129
+
130
+ /** 缓存目录名(也是产物目录的键):`<pkg>@<version>`,scope 的 `/` 转义成 `+`。 */
131
+ export function cacheKeyOf(name: string, version: string): string {
132
+ return `${encodePackageName(name)}@${version}`;
133
+ }
134
+
135
+ /**
136
+ * 目录/文件的内容指纹(名字 + 内容)。
137
+ *
138
+ * 先按**元信息**(名字 + 大小 + mtime)攒一个快速戳:戳没变就直接复用上次的指纹——否则每次请求都要把
139
+ * 每个包的源码**读一遍**(几百个包 × 每个包几十个文件),首屏全耗在算指纹上。
140
+ */
141
+ const fingerprints = new Map<string, { stamp: string; rev: string }>();
142
+
143
+ async function fingerprint(paths: readonly string[]): Promise<string> {
144
+ const key = paths.join("|");
145
+ const stampHash = createHash("sha1");
146
+ const collect = async (path: string): Promise<void> => {
147
+ const info = await stat(path).catch(() => undefined);
148
+ if (info === undefined) return;
149
+ if (!info.isDirectory()) {
150
+ stampHash.update(
151
+ `${relative(paths[0] ?? path, path)
152
+ .split(sep)
153
+ .join("/")}:${String(info.size)}:${String(info.mtimeMs)}`,
154
+ );
155
+ return;
156
+ }
157
+ const entries = await readdir(path, { withFileTypes: true }).catch(() => []);
158
+ for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
159
+ await collect(join(path, entry.name));
160
+ }
161
+ };
162
+ for (const path of paths) await collect(path);
163
+ const stamp = stampHash.digest("hex");
164
+ const cached = fingerprints.get(key);
165
+ if (cached?.stamp === stamp) return cached.rev;
166
+
167
+ const hash = createHash("sha1");
168
+ const walk = async (path: string): Promise<void> => {
169
+ const info = await stat(path).catch(() => undefined);
170
+ if (info === undefined) return;
171
+ if (!info.isDirectory()) {
172
+ hash.update(
173
+ `${relative(paths[0] ?? path, path)
174
+ .split(sep)
175
+ .join("/")}:${String(info.size)}`,
176
+ );
177
+ hash.update(await readFile(path));
178
+ return;
179
+ }
180
+ const entries = await readdir(path, { withFileTypes: true }).catch(() => []);
181
+ for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
182
+ await walk(join(path, entry.name));
183
+ }
184
+ };
185
+ for (const path of paths) await walk(path);
186
+ const rev = hash.digest("hex").slice(0, 16);
187
+ fingerprints.set(key, { stamp, rev });
188
+ return rev;
189
+ }
190
+
191
+ /**
192
+ * 说明符 → 联邦地址(同步:`paths` 回调与 vite 的 `resolveId` 都要求同步可答)。
193
+ *
194
+ * 两端共用这一份:产物里外置的 `import` / `require`,与 dev 侧源码里的裸说明符——同一条链、同一种地址。
195
+ *
196
+ * `?v=` 是**构建世代**({@link BUNDLER_REV},与 vite 同名的 query 参数):所有地址一次会话内稳定一致——同一个包**只有一个 URL**
197
+ * (多一个 URL 就多一份模块实例)。内容变了**不换 URL**,靠 `cache-control: no-cache` + `ETag` 协商
198
+ * ——vite dev 对它自己的模块也是这套。
199
+ */
200
+ /**
201
+ * **本包视角**的可外置包与版本:从引它的那个包的清单出发,用 `findPackageJSON` 按 node 的解析找。
202
+ *
203
+ * 两点都要紧:
204
+ * - **按包解析**:不同包的依赖树不同(peer 分叉时同一个名字可以是不同版本),全局一张表必然猜错;
205
+ * - **不碰布局**:`node:module` 的解析对 hoisted / `.pnpm` / 实体目录一视同仁——我们不该知道 pnpm 把包放哪儿。
206
+ *
207
+ * 候选是"这个包声明的依赖"(`dependencies` + `peerDependencies`):声明过、又能解析到,才谈得上外置;
208
+ * 解析不到的(可选依赖、纯 host 包)不列,产物里内联或由上层如实报错。
209
+ *
210
+ * @param manifestPath - 本包清单的绝对路径。
211
+ * @returns 包名 → 版本(供 {@link urlOf} 写地址)。
212
+ */
213
+ /** 本包视角版本表的缓存:**键是"包 + 版本"**(与产物缓存目录 `cacheKeyOf` 同一形态)——同一个包的同一个版本只解析一次。 */
214
+ const resolvableCache = new Map<string, Promise<Map<string, string>>>();
215
+
216
+ /**
217
+ * 带缓存的前者:按"包 + 版本"复用解析结果。
218
+ *
219
+ * @param key - `cacheKeyOf(name, version)`(与产物目录名同一串)。
220
+ * @param manifestPath - 本包清单的绝对路径。
221
+ * @returns 包名 → 版本。
222
+ */
223
+ export async function resolvableVersionsFor(
224
+ key: string,
225
+ manifestPath: string | undefined,
226
+ ): Promise<Map<string, string>> {
227
+ const cached = resolvableCache.get(key);
228
+ if (cached !== undefined) return await cached;
229
+ const computed = resolvableVersionsOf(manifestPath);
230
+ resolvableCache.set(key, computed);
231
+ return await computed;
232
+ }
233
+
234
+ export async function resolvableVersionsOf(
235
+ manifestPath: string | undefined,
236
+ ): Promise<Map<string, string>> {
237
+ const versions = new Map<string, string>();
238
+ if (manifestPath === undefined) return versions;
239
+ const here = pathToFileURL(manifestPath).href;
240
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8").catch(() => "{}")) as {
241
+ dependencies?: Record<string, string>;
242
+ peerDependencies?: Record<string, string>;
243
+ peerDependenciesMeta?: Record<string, { readonly optional?: boolean }>;
244
+ };
245
+ // **可选 peer 不是候选**:它可以不被任何消费方提供(`zustand` 的 `@types/react`、`immer` 都是这种),
246
+ // 声明它只是"你若提供我就用"。
247
+ const names = new Set([
248
+ ...Object.keys(manifest.dependencies ?? {}),
249
+ ...Object.keys(manifest.peerDependencies ?? {}).filter(
250
+ (name) => manifest.peerDependenciesMeta?.[name]?.optional !== true,
251
+ ),
252
+ ]);
253
+ for (const name of names) {
254
+ // **解析不到的也不是候选**:它不在闭包里(声明了没装、或只在开发/构建期存在),外置它只会让产物留一条
255
+ // 悬空的 import。判据是"声明过**且**能解析到"——解析失败在这里是**预期**结果,不是异常。
256
+ let found: string | undefined;
257
+ try {
258
+ found = findPackageJSON(name, here);
259
+ } catch {
260
+ continue;
261
+ }
262
+ if (found === undefined) continue;
263
+ const parsed = JSON.parse(await readFile(found, "utf8").catch(() => "{}")) as {
264
+ version?: unknown;
265
+ };
266
+ if (typeof parsed.version === "string") versions.set(name, parsed.version);
267
+ }
268
+ return versions;
269
+ }
270
+
271
+ export function urlOf(
272
+ specifier: string,
273
+ tables: { versions?: ReadonlyMap<string, string>; clientRows?: ReadonlySet<string> },
274
+ ): string {
275
+ const bare = packageNameOf(specifier) || specifier;
276
+ const version = tables.versions?.get(bare) ?? "0.0.0";
277
+ // **子路径引用**(`pkg/sub`,如 rolldown 注入的 `@oxc-project/runtime/helpers/decorate`、三方的
278
+ // `katex/dist/katex.min.css`)保留子路径——也**不要**自作主张加 `.js`(子路径本身可能带扩展名:`.css`)。
279
+ //
280
+ // **包级引用**的出口按"它是不是行"分(`dsh.client` + client 半,由 loader 给):
281
+ // - **行** → `client.js`:行的客户端产物就是它的 client 半。以前这里向一张"包名 → client/index"的表要
282
+ // 出口名,那张表是按 `exports` 里有没有 `./client` 猜的——于是没有该键的行(`@deepseek-ai/dsh-api-*`)
283
+ // 被打成 `index.js`(宿主半)。
284
+ // - **普通 npm 包**(三方、被依赖的)→ `index.js`:出口按 npm 语义(包的根出口)。
285
+ // **子路径引用**(`pkg/sub`)保留子路径——但**扩展名要统一**:产物名一律 `<出口名>.js`(`entryFileNames`),
286
+ // 所以只带包内路径的引用(`zustand/vanilla`)要补 `.js`,否则 URL 一半带(`index.js`)、一半不带(`vanilla`),
287
+ // 而磁盘上的文件名全都带。本来就有扩展名的(`katex/dist/katex.min.css`)原样。
288
+ const subpath = specifier === bare ? "" : specifier.slice(bare.length + 1);
289
+ // 扩展名统一走 {@link entryFileNameOf} 那条约定:本来有扩展名的(`katex/dist/katex.min.css`)原样,
290
+ // 其余按"出口产物文件名 = `<出口名>.js`"补上。
291
+ const file =
292
+ subpath !== ""
293
+ ? /\.[a-z0-9]+$/iu.test(subpath)
294
+ ? subpath
295
+ : entryFileNameOf(subpath)
296
+ : entryFileNameOf(tables.clientRows?.has(bare) === true ? "client" : "index");
297
+ return depsUrlOf(specifier, version, `${file}?v=${BUNDLER_REV}`);
298
+ }
299
+
300
+ /**
301
+ * 依赖里的 `process.env`。
302
+ *
303
+ * 顶层的 `define` 只作用于我们的源码面:`node_modules` 里的包直接进产物,而 `immer@10` 的
304
+ * `dist/immer.mjs` 在模块顶层就读 `process.env.NODE_ENV`——浏览器里没有 `process`,一个
305
+ * `process is not defined` 会把整份产物带下去。
306
+ *
307
+ * **只替换 `process.env` 系列**:裸的 `process` 不能碰——它可能只是个普通标识符(`zod` 里就有一个叫
308
+ * `process` 的函数,替换掉直接把源码变成语法错误)。
309
+ */
310
+ function browserProcessPlugin(): PluginOption {
311
+ const replace = (code: string): string =>
312
+ code
313
+ .replaceAll(/\bprocess\.env\.NODE_ENV\b/gu, JSON.stringify("development"))
314
+ .replaceAll(/\bprocess\.env\b/gu, "({ env: {} })");
315
+ return {
316
+ name: "dsh-browser-process",
317
+ enforce: "pre",
318
+ transform(code: string) {
319
+ if (!code.includes("process.env")) return null;
320
+ return { code: replace(code), map: null };
321
+ },
322
+ };
323
+ }
324
+
325
+ /**
326
+ * CJS 入口的**具名导出包装**(`react` 这类)。
327
+ *
328
+ * `react` 的入口是 `module.exports = require('./cjs/react.development.js')`,rolldown 静态分析不出具名
329
+ * 导出,产物只剩 `export default …`——而浏览器侧要 `import { Fragment } from "react"`,于是**整个 import
330
+ * 阶段就失败**(入口一行都执行不了)。ESM 的导出必须**静态可枚举**(不能 `export * from` 一个运行期对象),
331
+ * 所以这里求值入口拿到它的键,生成一份把它们写成 `export const` 的包装文件,拿包装文件当入口。
332
+ *
333
+ * @returns 包装文件路径;入口不是 CJS、或求值拿不到键时 undefined(保持原样)。
334
+ */
335
+ async function cjsShimOf(options: {
336
+ name: string;
337
+ exportName: string;
338
+ entry: string;
339
+ outDir: string;
340
+ log?: (message: string) => void;
341
+ }): Promise<string | undefined> {
342
+ const source = await readFile(options.entry, "utf8").catch(() => "");
343
+ // 判据是**CJS 入口**:`.js`/`.cjs` 且源码里真的在赋值 `module.exports` / `exports.x`(`react` 的入口是
344
+ // `module.exports = require('./cjs/…')`)。TS 源码不在此列——那是 ESM;按"含 `exports.` 子串"判断会把
345
+ // 注释里的 `re-exports.` 也当成 CJS,反过来给一份纯 ESM 加 `import mod from …` 而构建失败。
346
+ const isCjsEntry =
347
+ /\.(?:cjs|js)$/u.test(options.entry) &&
348
+ /(?:^|\n)\s*(?:module\.exports\s*=|exports\.[A-Za-z_$][A-Za-z0-9_$]*\s*=)/u.test(source);
349
+ if (!isCjsEntry) return undefined;
350
+ let keys: string[];
351
+ try {
352
+ // 求值才知道键:CJS 的导出面是**运行期**的(静态分析只能看到 `module.exports = require(…)`)。
353
+ const loaded = createRequire(options.entry)(options.entry) as
354
+ | Record<string, unknown>
355
+ | undefined;
356
+ keys = Object.keys(loaded ?? {});
357
+ } catch {
358
+ // 求值有副作用、或本来就是不能在 node 里加载的东西:宁可只有 default 导出,也不要让构建挂掉。
359
+ return undefined;
360
+ }
361
+ const named = keys.filter((key) => key !== "default" && /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key));
362
+ if (named.length === 0) return undefined;
363
+ const file = join(options.outDir, `${options.exportName.replaceAll("/", "+")}.cjs-shim.mjs`);
364
+ await mkdir(dirname(file), { recursive: true });
365
+ await writeFile(
366
+ file,
367
+ [
368
+ `import mod from ${JSON.stringify(options.entry)};`,
369
+ // 用**计算属性**读:`mod.jsxDEV` 这种静态属性访问会被 rolldown 按它对 CJS 命名空间的静态理解
370
+ // 摇成 `void 0`(`react/jsx-dev-runtime` 就那么被摇空了,页面报 `jsxDEV is not a function`)。
371
+ "const __cjs = mod ?? {};",
372
+ "export default __cjs;",
373
+ ...named.map((key) => `export const ${key} = __cjs[${JSON.stringify(key)}];`),
374
+ "",
375
+ ].join("\n"),
376
+ "utf8",
377
+ );
378
+ options.log?.(
379
+ `[bundle-cjs] ${options.name}/${options.exportName}: 补 ${String(named.length)} 个具名导出\n`,
380
+ );
381
+ return file;
382
+ }
383
+
384
+ /**
385
+ * CSS 入口的包装:**样式文件不能直接当入口**——vite 的库模式固定 `cssCodeSplit: false`,遇到
386
+ * `rolldownOptions.input` 里有 `.css` 直接报错。包一层 side-effect import,让样式插件按它一贯的方式
387
+ * 把它编成"注入 `<style>`"的模块。
388
+ *
389
+ * @returns 包装文件路径;入口不是 CSS 时 undefined。
390
+ */
391
+ async function cssShimOf(options: {
392
+ exportName: string;
393
+ entry: string;
394
+ outDir: string;
395
+ }): Promise<string | undefined> {
396
+ if (!options.entry.endsWith(".css")) return undefined;
397
+ const file = join(options.outDir, `${options.exportName.replaceAll("/", "+")}.css-shim.mjs`);
398
+ await mkdir(dirname(file), { recursive: true });
399
+ await writeFile(file, `import ${JSON.stringify(options.entry)};\n`, "utf8");
400
+ return file;
401
+ }
402
+
403
+ /**
404
+ * 确保该包的产物在(指纹没变、且**这次要的出口**都在就直接用)。
405
+ *
406
+ * 缓存是**按包聚合**的(一个包一个目录、出口各有自己的产物文件),而构建**按请求触发单出口**——
407
+ * 所以命中判据必须带上"这次要的出口在不在":只比指纹会把"先请求 `zustand/middleware`、再请求
408
+ * `zustand/vanilla`"判成命中,第二个出口就 404 了。指纹变了才丢掉旧目录重来,否则增量合并。
409
+ *
410
+ * **同一个包的构建排队串行**:产物目录是包级共享的(还会被重建时清理),并发跑会互相删掉对方的产物——
411
+ * 首屏那几十个请求是同时在飞的,`zustand/vanilla` 与 `zustand/shallow` 撞在一起就丢产物、页面 404。
412
+ *
413
+ * @param options - 见 {@link BundleOptions}。
414
+ * @returns 产物映射;`input` 为空时 undefined。
415
+ */
416
+ /**
417
+ * 摆正 **cwd** 之后再进构建。
418
+ *
419
+ * rolldown 的 oxc 转译是**按 cwd 往上找 tsconfig** 拿 `experimentalDecorators` 的,而这条链跑在宿主
420
+ * 进程里——cwd 是宿主的(dev 是工作区、打包是 profile)。不摆正,上游用 cordis 装饰器的包就会在产物里
421
+ * 留着 `@Remote(...)`,浏览器报 `SyntaxError`、入口整条不执行(这是"不同工作目录行为不一致"里最隐蔽的一处)。
422
+ * 跑完恢复,别把进程的 cwd 改坏了。
423
+ */
424
+ export async function bundlePackage(options: BundleOptions): Promise<Bundled | undefined> {
425
+ const previousCwd = process.cwd();
426
+ if (previousCwd !== options.repoRoot) process.chdir(options.repoRoot);
427
+ try {
428
+ return await bundlePackageInRoot(options);
429
+ } finally {
430
+ if (process.cwd() !== previousCwd) process.chdir(previousCwd);
431
+ }
432
+ }
433
+
434
+ async function bundlePackageInRoot(options: BundleOptions): Promise<Bundled | undefined> {
435
+ const key = cacheKeyOf(options.name, options.version);
436
+ // 同一个包**排队**:产物目录包级共享,而"这次要的出口"可能已经在上一轮里产出(第二个请求跑到
437
+ // `bundlePackageOnce` 开头就会命中缓存,不会重建)。
438
+ const previous = building.get(key) ?? Promise.resolve();
439
+ const task = previous
440
+ .catch(() => undefined)
441
+ .then(() => withBuildSlot(() => bundlePackageOnce(options, key)));
442
+ building.set(key, task);
443
+ try {
444
+ // **排队要等它做完,但不能无限等**:首屏那几十个请求都在飞,同包的后来者排在队里是正常的;可要是
445
+ // 一次构建卡死(原生绑定、磁盘),排队的人就永远挂在 pending——浏览器那边看不出所以然。超时如实抛。
446
+ return await withTimeout(task, BUILD_TIMEOUT_MS, `${options.name}@${options.version} 构建超时`);
447
+ } finally {
448
+ if (building.get(key) === task) building.delete(key);
449
+ }
450
+ }
451
+
452
+ /**
453
+ * 同时在跑的构建数上限。
454
+ *
455
+ * 每次联邦构建都是一次完整的 vite build(自建实例、加载插件、跑 rolldown),首屏那两百多个包**一起上**
456
+ * 只会互相拖慢、把内存吃满——排队反而更快。要调就设 `DSH_BUNDLE_CONCURRENCY`。
457
+ */
458
+ const MAX_CONCURRENT_BUILDS = Math.max(1, Number(process.env["DSH_BUNDLE_CONCURRENCY"] ?? 8));
459
+ let runningBuilds = 0;
460
+ const buildQueue: (() => void)[] = [];
461
+
462
+ /** 排队/构建的等待上限:等它做完,但不无限等(卡死的构建要如实报出来,而不是让请求挂在 pending)。 */
463
+ const BUILD_TIMEOUT_MS = Math.max(1_000, Number(process.env["DSH_BUNDLE_TIMEOUT_MS"] ?? 180_000));
464
+
465
+ /** 给一个 promise 加超时;超时如实抛,不返回兜底值。 */
466
+ async function withTimeout<T>(work: Promise<T>, ms: number, subject: string): Promise<T> {
467
+ let timer: NodeJS.Timeout | undefined;
468
+ try {
469
+ return await Promise.race([
470
+ work,
471
+ new Promise<never>((_resolve, reject) => {
472
+ timer = setTimeout(() => {
473
+ reject(new Error(`${subject}(超过 ${String(ms)}ms)`));
474
+ }, ms);
475
+ }),
476
+ ]);
477
+ } finally {
478
+ if (timer !== undefined) clearTimeout(timer);
479
+ }
480
+ }
481
+
482
+ async function withBuildSlot<T>(work: () => Promise<T>): Promise<T> {
483
+ if (runningBuilds >= MAX_CONCURRENT_BUILDS) {
484
+ await new Promise<void>((resolve) => buildQueue.push(resolve));
485
+ }
486
+ runningBuilds += 1;
487
+ try {
488
+ return await work();
489
+ } finally {
490
+ runningBuilds -= 1;
491
+ buildQueue.shift()?.();
492
+ }
493
+ }
494
+
495
+ /** 排队中/在跑的包级构建(键是缓存目录名)。 */
496
+ const building = new Map<string, Promise<Bundled | undefined>>();
497
+
498
+ async function bundlePackageOnce(
499
+ options: BundleOptions,
500
+ key: string,
501
+ ): Promise<Bundled | undefined> {
502
+ const wanted = Object.keys(options.input);
503
+ options.log?.(
504
+ `[bundle-in] ${options.name}: input=${wanted.join(",")} cache=${options.cacheRoot}\n`,
505
+ );
506
+ if (wanted.length === 0) return undefined;
507
+ const outDir = join(options.cacheRoot, key);
508
+ const manifestPath = join(outDir, ".files.json");
509
+ options.log?.(`[bundle-rev] ${options.name}: hash-start watch=${options.watch.join(",")}\n`);
510
+ const rev = `${BUNDLER_REV}:${await fingerprint(options.watch)}`;
511
+ options.log?.(`[bundle-rev] ${options.name}: hash=${rev}\n`);
512
+ const cached = JSON.parse(await readFile(manifestPath, "utf8").catch(() => "{}")) as {
513
+ rev?: string;
514
+ files?: Record<string, string>;
515
+ };
516
+ const fresh = cached.rev === rev;
517
+ const files = fresh ? { ...cached.files } : {};
518
+ if (fresh && wanted.every((name) => name in files)) {
519
+ return { rev, files: new Map(Object.entries(files)) };
520
+ }
521
+
522
+ // **同一份配置**:CSS / 资产 / `?raw` / node 内置替身与 dev 用的是同一个插件;只有 alias 由调用方给。
523
+ // 资产走 `threshold`:产品是我们自己落盘、自己服务的,大图产出独立文件即可,不必塞进 JS 里当 base64。
524
+ // **本包视角**的版本表:外置名单与地址里的版本都从它来(见 `resolvableVersionsOf`)。
525
+ const resolvable = await resolvableVersionsFor(key, options.manifestOf);
526
+ const plugins: (PluginOption | Plugin)[] = [
527
+ // alias:rolldown 的 `resolve.alias` 只吃"字符串 → 字符串"的对象,而我们的 alias 带正则
528
+ // (`^@/`)——用一个最小插件表达,放在最前面(它相当于 vite 里 `enforce: "pre"` 的那一层)。
529
+ {
530
+ name: "dsh-alias",
531
+ resolveId: (source: string): string | undefined => {
532
+ for (const { find, replacement } of options.alias) {
533
+ if (typeof find === "string" ? source.startsWith(find) : find.test(source)) {
534
+ return source.replace(find, replacement);
535
+ }
536
+ }
537
+ return undefined;
538
+ },
539
+ },
540
+ browserProcessPlugin(),
541
+ cssInlinePlugins({
542
+ name: key,
543
+ assets: "threshold",
544
+ // 资产地址要是**联邦地址**:`assets/…` 只相对页面,而这条链的资产由我们自己的路由按
545
+ // `/@client-modules/<pkg>@<ver>/assets/…` 服务(清单是唯一真相)。
546
+ assetUrl: (file) => `${CLIENT_MODULES_PREFIX}/${key}/${file}`,
547
+ }),
548
+ clientAssetPlugin({ assets: "threshold" }),
549
+ // 外置**全部裸说明符**(`node:*` 除外——它走 `clientAssetPlugin` 的浏览器替身)。
550
+ //
551
+ // 这件事交给这个内置插件而不是顶层 `external`:CJS 模块 `require` 一个外置依赖时,rolldown 默认保留
552
+ // `require` 语义(产物里是运行期必抛的 `__require(...)`——浏览器没有 `require`),而这个插件把这类
553
+ // require 转成 ESM import。它**必须独占**外置名单:顶层 `external` 一旦也列了同一个模块,它就整块跳过,
554
+ // 产物里又留下 `require`。
555
+ // **外置名单 = 包名**:凡是"一个包"(有自己的 `package.json`)就独立成联邦产物,地址由 `output.paths`
556
+ // 交给 `urlOf`;不是包的(相对路径、内置、虚拟模块)留在产物里内联。
557
+ //
558
+ // 名单就是手里那张"包名 → 版本"表的键——它本来就是"这条链认识哪些包"这份事实。写成 `^名(/|$)`:
559
+ // 既覆盖子路径引用(`katex/dist/katex.min.css`),又不会把 `node:fs`、`./x` 误判成包。
560
+ //
561
+ // 名单只有这一个 home:顶层 `external` 不能再列同一个模块(插件会整块跳过,产物里留下 `__require`)。
562
+ esmExternalRequirePlugin({
563
+ // **外置名单 = 这条链认识的那些包**(包名 → 版本表的键),node 内置除外(它们走替身)。
564
+ // 不是包的(相对路径、内置、虚拟模块)留在产物里内联。
565
+ external: [...resolvable.keys()]
566
+ .filter((name) => !NODE_BUILTIN.test(name))
567
+ .map((name) => barePackageRegExp(name)),
568
+ }),
569
+ ];
570
+ const startedAt = Date.now();
571
+ options.log?.(`[bundle-build] ${options.name}: rolldown start\n`);
572
+ // 入口补偿:CJS 要带具名导出的包装;CSS 不能直接当入口(vite 的库模式 `cssCodeSplit: false`,
573
+ // 明确拒绝 `rolldownOptions.input` 里出现 CSS 文件),包一层 side-effect import 让样式插件处理它。
574
+ const input: Record<string, string> = {};
575
+ for (const [exportName, entry] of Object.entries(options.input)) {
576
+ input[exportName] =
577
+ (await cjsShimOf({
578
+ name: options.name,
579
+ exportName,
580
+ entry,
581
+ ...(options.log === undefined ? {} : { log: options.log }),
582
+ outDir,
583
+ })) ??
584
+ (await cssShimOf({ exportName, entry, outDir })) ??
585
+ entry;
586
+ }
587
+ // **直接跑 rolldown**(不经过 `vite build`):插件本来就是 rolldown 的接口,而 vite 在这条路上只提供了
588
+ // lib 模式的入口/输出命名与 `worker.format`——那几项自己写更直接,且 `build.rolldownOptions` 里的
589
+ // `transform` / `resolve` 也不会再被 vite 吞掉(对照表见债文档,那份债随这次改动一起销)。
590
+ const bundle = await rolldown({
591
+ input,
592
+ plugins,
593
+ // **不指 tsconfig**:装饰器由下面的 `transform.decorator.legacy` 直接开(直出时它生效,见对照表)。
594
+ // rolldown 自己会解析 tsconfig(含 `extends`,实测过),所以两种形态各指一处不如一处都不指。
595
+ ...(options.tsconfig === undefined ? {} : { resolve: { tsconfigFilename: options.tsconfig } }),
596
+ transform: {
597
+ // **浏览器 baseline**(vite 8 的默认那组:Chrome 111 / Edge 111 / Firefox 114 / Safari 16.4)。
598
+ // 直出后没有 `build.target: "baseline-widely-available"` 那层展开,这里自己写全。不要图省事写
599
+ // `esnext` 或留空——目标一低,oxc 就给类字段、可选链这些逐个加降级 helper,产物里全是奇怪的垫片。
600
+ target: ["chrome111", "edge111", "firefox114", "safari16.4"],
601
+ // 装饰器:legacy(cordis 与上游用的就是这套)。有 tsconfig 时它会被那份覆盖,这里是默认。
602
+ decorator: { legacy: true },
603
+ // 浏览器没有 `process`:精确替换 `process.env`,裸 `process` 再给一个空壳——`immer` 这类包会在
604
+ // 模块顶层读它,漏掉就是一个 `process is not defined` 把整份产物带下去。
605
+ define: {
606
+ "process.env": "{ env: {} }",
607
+ process: "{ env: {} }",
608
+ },
609
+ },
610
+ });
611
+ const { output: items } = await bundle.generate({
612
+ format: "es",
613
+ // 出口名可以含 `/`(子路径出口:`helpers/decorate`),rolldown 会建父目录。
614
+ entryFileNames: "[name].js",
615
+ // 拆出来的块落**自己的前缀**(与资产 `assets/` 并列):它们不是"包的出口"——出口名要回源码里找目标,
616
+ // 而块没有源码对应(`src/chunk-abc.js` 不存在)。与出口共用一段 URL,服务端就只能把它当出口、落空。
617
+ chunkFileNames: "chunks/[name]-[hash].js",
618
+ // 资产统一落在 `assets/` 下、带内容 hash:路由按这个前缀把它们当文件吐,
619
+ // 出口产物与资产不混在一个命名空间里。
620
+ assetFileNames: "assets/[name]-[hash][extname]",
621
+ // 外置说明符 → 联邦地址(`/@client-modules/<pkg>@<ver>/…`)。
622
+ paths: (id: string) =>
623
+ urlOf(id, {
624
+ versions: resolvable,
625
+ ...(options.clientRows === undefined ? {} : { clientRows: options.clientRows }),
626
+ }),
627
+ });
628
+ await bundle.close();
629
+ options.log?.(
630
+ `[bundle-build] ${options.name}: rolldown done ms=${String(Date.now() - startedAt)}\n`,
631
+ );
632
+
633
+ if (!items.some((item) => item.type === "chunk")) {
634
+ options.log?.(
635
+ `[bundle] ${options.name}: 输出为空(input=${wanted.join(",")} outputs=${String(items.length)})\n`,
636
+ );
637
+ return undefined;
638
+ }
639
+ // 指纹没变就**增量合并**:别的出口的产物还在磁盘上、还在 manifest 里,删了就得让它们重新构建一次。
640
+ //
641
+ // 指纹变了也**不整目录删**:这个缓存是**跨进程共用**的(同时跑 `dev --web` 与桌面 dev 是常态),删掉整个
642
+ // 目录会把另一个进程刚写的产物一起带走,表现为偶发 404。产物名本身带语义(出口名 / 资产 hash),直接
643
+ // 覆盖即可;旧的字节留在盘上不碍事。
644
+ await mkdir(outDir, { recursive: true });
645
+ for (const item of items) {
646
+ if (item.type !== "chunk") continue;
647
+ // 出口名可以含 `/`(子路径出口:`helpers/decorate`),落盘要先把父目录建出来。
648
+ await mkdir(dirname(join(outDir, item.fileName)), { recursive: true });
649
+ await writeFile(join(outDir, item.fileName), item.code, "utf8");
650
+ // **出口产物**按出口名进清单(路由按出口名找它);**拆出来的块**(`import()` 各成 chunk)按文件名——
651
+ // 引它的产物写的就是那个名字(`./chunk-<hash>.js`),它没有第二个身份。只记出口名的话这些块不在清单里,
652
+ // 路由按清单服务就 404,浏览器报 "does not provide an export named …"。
653
+ files[item.isEntry ? item.name : item.fileName] = `${key}/${item.fileName}`;
654
+ }
655
+ // 资产也进清单(键就是它在包内的相对路径 `assets/<name>-<hash>.<ext>`):清单是"这个包产出里有什么"
656
+ // 的**唯一真相**——路由按它服务资产,缓存命中/增量合并也照它判断,不必去猜目录里躺了什么。
657
+ for (const asset of items) {
658
+ if (asset.type !== "asset") continue;
659
+ await mkdir(dirname(join(outDir, asset.fileName)), { recursive: true });
660
+ await writeFile(join(outDir, asset.fileName), asset.source ?? "");
661
+ files[asset.fileName] = `${key}/${asset.fileName}`;
662
+ }
663
+ await writeFile(manifestPath, `${JSON.stringify({ rev, files }, null, 2)}\n`, "utf8");
664
+ // 指纹进地址用的表({@link revs}):**每次构建后更新**——同一会话里后构建的产物引用它时就能带上
665
+ // 当前指纹(浏览器于是可以长缓存那一份)。
666
+ //
667
+ // 这样会自然产生"同一个包的两种 URL"(上一轮落盘的产物写的是旧指纹、这一轮写的是新指纹)——**不要紧**:
668
+ // 服务端对指纹不匹配的请求一律**重定向到当前指纹**(见 `serveFederation`),所有引用最终落到同一个 URL,
669
+ // 不会出现两份模块实例(那才是致命的:react 会报 `Cannot read properties of null (reading 'useContext')`)。
670
+ return { rev, files: new Map(Object.entries(files)) };
671
+ }
672
+
673
+ /** 三方的入口:按 node 的解析找(pnpm 的软链由它解开)。 */
674
+ export async function dependencyEntry(
675
+ from: string,
676
+ name: string,
677
+ ): Promise<{ entry: string; version: string } | undefined> {
678
+ const require = createRequire(from);
679
+ let entry: string;
680
+ try {
681
+ entry = require.resolve(name);
682
+ } catch {
683
+ return undefined;
684
+ }
685
+ const manifest = await packageJsonNear(entry);
686
+ const parsed = JSON.parse(await readFile(manifest, "utf8").catch(() => "{}")) as {
687
+ version?: string;
688
+ };
689
+ return { entry, version: parsed.version ?? "0.0.0" };
690
+ }
691
+
692
+ /** 从入口文件向上找它所属包的 `package.json`。 */
693
+ async function packageJsonNear(file: string): Promise<string> {
694
+ let directory = dirname(file);
695
+ for (;;) {
696
+ const candidate = join(directory, "package.json");
697
+ if (
698
+ await readFile(candidate, "utf8").then(
699
+ () => true,
700
+ () => false,
701
+ )
702
+ )
703
+ return candidate;
704
+ const parent = dirname(directory);
705
+ if (parent === directory) return candidate;
706
+ directory = parent;
707
+ }
708
+ }