@morlay/dsh-client-modules 0.2.11-alpha.2 → 0.2.11
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/dist/client.cjs +115 -93
- package/dist/index.cjs +376 -57
- package/dist/index.mjs +377 -58
- package/package.json +8 -8
- package/src/__tests__/bundle.spec.ts +373 -3
- package/src/__tests__/client-meta.spec.ts +114 -0
- package/src/__tests__/resolve.spec.ts +20 -2
- package/src/bundle.ts +330 -73
- package/src/client-meta.ts +128 -0
- package/src/index.ts +13 -3
- package/src/resolve.ts +2 -1
- package/src/serve-vite.ts +14 -16
package/src/bundle.ts
CHANGED
|
@@ -12,12 +12,13 @@ import { createHash } from "node:crypto";
|
|
|
12
12
|
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { findPackageJSON } from "node:module";
|
|
15
|
-
import { dirname, join, relative, sep } from "node:path";
|
|
15
|
+
import { basename, dirname, extname, join, relative, sep } from "node:path";
|
|
16
16
|
import { pathToFileURL } from "node:url";
|
|
17
17
|
import { clientAssetPlugin, NODE_BUILTIN } from "@morlay/dsh-cordis-assets/assets";
|
|
18
18
|
import { cssInlinePlugins } from "@morlay/dsh-cordis-assets/styles";
|
|
19
19
|
import { rolldown, type Plugin } from "rolldown";
|
|
20
20
|
import { esmExternalRequirePlugin, type PluginOption } from "vite";
|
|
21
|
+
import { type ClientBuildMeta, clientBuildMeta, clientMetaDefines } from "./client-meta.ts";
|
|
21
22
|
import { encodePackageName, entryFileNameOf, packageNameOf } from "./resolve.ts";
|
|
22
23
|
|
|
23
24
|
/** 裸包名 → `^名(/|$)`:既覆盖子路径引用,也不会把 `node:fs`、`./x` 误判成包。 */
|
|
@@ -61,7 +62,7 @@ export function clientModulesAlias(options: {
|
|
|
61
62
|
* 规则变了、源码没变时,旧产物必须作废,否则会继续复用带着老 URL 的字节。
|
|
62
63
|
*/
|
|
63
64
|
/** 构建器**规则**版本:改解析 / 改写规则就 bump(见 {@link BUNDLER_REV})。 */
|
|
64
|
-
const BUNDLER_RULES = "
|
|
65
|
+
const BUNDLER_RULES = "36";
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
68
|
* 地址里的 `?v=`(与 vite 同一个写法):构建器**规则指纹**(`BUNDLER_RULES` 的哈希,形如 vite 的 `?v=`)。
|
|
@@ -96,7 +97,7 @@ export interface BundleOptions {
|
|
|
96
97
|
name: string;
|
|
97
98
|
/** 包版本(进缓存键与地址)。 */
|
|
98
99
|
version: string;
|
|
99
|
-
/**
|
|
100
|
+
/** 出口名 → 入口文件(由调用方的清单解析得到:`exports` 条件 / `module` / `main`)。 */
|
|
100
101
|
input: Record<string, string>;
|
|
101
102
|
/** 指纹的输入文件集(我们的包给 `src`;三方给它的入口)。 */
|
|
102
103
|
watch: readonly string[];
|
|
@@ -307,11 +308,15 @@ export function urlOf(
|
|
|
307
308
|
* **只替换 `process.env` 系列**:裸的 `process` 不能碰——它可能只是个普通标识符(`zod` 里就有一个叫
|
|
308
309
|
* `process` 的函数,替换掉直接把源码变成语法错误)。
|
|
309
310
|
*/
|
|
310
|
-
function browserProcessPlugin(): PluginOption {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
311
|
+
export function browserProcessPlugin(meta: ClientBuildMeta = {}): PluginOption {
|
|
312
|
+
// 版本元数据那几个键**先**换成字面量:它们要的是真值(设置页的版本行、侧栏的 `<commit>-dirty`),
|
|
313
|
+
// 而后面那条把 `process.env` 整个换成空壳的规则会连它们一起吞掉。
|
|
314
|
+
const metaDefines = Object.entries(clientMetaDefines(meta));
|
|
315
|
+
const replace = (code: string): string => {
|
|
316
|
+
let next = code.replaceAll(/\bprocess\.env\.NODE_ENV\b/gu, JSON.stringify("development"));
|
|
317
|
+
for (const [key, value] of metaDefines) next = next.replaceAll(key, value);
|
|
318
|
+
return next.replaceAll(/\bprocess\.env\b/gu, "({ env: {} })");
|
|
319
|
+
};
|
|
315
320
|
return {
|
|
316
321
|
name: "dsh-browser-process",
|
|
317
322
|
enforce: "pre",
|
|
@@ -323,81 +328,262 @@ function browserProcessPlugin(): PluginOption {
|
|
|
323
328
|
}
|
|
324
329
|
|
|
325
330
|
/**
|
|
326
|
-
* CJS
|
|
331
|
+
* `export const <名>` 用不了的键:JS 的保留字(`zod@4` 的 CJS 面有 `function` / `void` / `null`
|
|
332
|
+
* 这些键,原样拼出去就是 `SyntaxError`)。
|
|
333
|
+
*/
|
|
334
|
+
const RESERVED_EXPORT_NAMES = new Set([
|
|
335
|
+
"await",
|
|
336
|
+
"break",
|
|
337
|
+
"case",
|
|
338
|
+
"catch",
|
|
339
|
+
"class",
|
|
340
|
+
"const",
|
|
341
|
+
"continue",
|
|
342
|
+
"debugger",
|
|
343
|
+
"default",
|
|
344
|
+
"delete",
|
|
345
|
+
"do",
|
|
346
|
+
"else",
|
|
347
|
+
"enum",
|
|
348
|
+
"eval",
|
|
349
|
+
"export",
|
|
350
|
+
"extends",
|
|
351
|
+
"false",
|
|
352
|
+
"finally",
|
|
353
|
+
"for",
|
|
354
|
+
"function",
|
|
355
|
+
"if",
|
|
356
|
+
"implements",
|
|
357
|
+
"import",
|
|
358
|
+
"in",
|
|
359
|
+
"instanceof",
|
|
360
|
+
"interface",
|
|
361
|
+
"let",
|
|
362
|
+
"new",
|
|
363
|
+
"null",
|
|
364
|
+
"package",
|
|
365
|
+
"private",
|
|
366
|
+
"protected",
|
|
367
|
+
"public",
|
|
368
|
+
"return",
|
|
369
|
+
"static",
|
|
370
|
+
"super",
|
|
371
|
+
"switch",
|
|
372
|
+
"this",
|
|
373
|
+
"throw",
|
|
374
|
+
"true",
|
|
375
|
+
"try",
|
|
376
|
+
"typeof",
|
|
377
|
+
"undefined",
|
|
378
|
+
"var",
|
|
379
|
+
"void",
|
|
380
|
+
"while",
|
|
381
|
+
"with",
|
|
382
|
+
"yield",
|
|
383
|
+
]);
|
|
384
|
+
|
|
385
|
+
/** ESM 的顶层语法节点:`import` / `export` 声明。 */
|
|
386
|
+
const ESM_STATEMENT_TYPES = new Set([
|
|
387
|
+
"ImportDeclaration",
|
|
388
|
+
"ExportNamedDeclaration",
|
|
389
|
+
"ExportDefaultDeclaration",
|
|
390
|
+
"ExportAllDeclaration",
|
|
391
|
+
]);
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* `.js` 且没有 `type` 时,按 **Node 的语法检测**判:有 ESM 语法(顶层 `import`/`export`)就是 ESM,
|
|
395
|
+
* 否则 CJS(Node 23+ 默认开启 `--experimental-detect-module`)。
|
|
396
|
+
*
|
|
397
|
+
* 用 rolldown 自带的解析器(`rolldown/parseAst`),不自己写词法扫描。
|
|
398
|
+
*
|
|
399
|
+
* @param entry - `.js` 入口的绝对路径。
|
|
400
|
+
* @returns 是否含 ESM 语法。
|
|
401
|
+
*/
|
|
402
|
+
async function hasModuleSyntax(entry: string): Promise<boolean> {
|
|
403
|
+
try {
|
|
404
|
+
const [{ parseAst }, source] = await Promise.all([
|
|
405
|
+
import("rolldown/parseAst"),
|
|
406
|
+
readFile(entry, "utf8"),
|
|
407
|
+
]);
|
|
408
|
+
const program = parseAst(source, null, entry) as unknown as {
|
|
409
|
+
body: readonly { type: string }[];
|
|
410
|
+
};
|
|
411
|
+
return program.body.some((node) => ESM_STATEMENT_TYPES.has(node.type));
|
|
412
|
+
} catch {
|
|
413
|
+
// 解析不了(CJS 专有语法在 module 模式下报错等):按 CJS 走,求值失败时保持原样。
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** 一个入口的模块类型。 */
|
|
419
|
+
export type ModuleKind = "esm" | "cjs" | "source" | "style" | "asset";
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* 一个入口的模块类型:**扩展名 + 最近的 `package.json`**,不猜源码。
|
|
423
|
+
*
|
|
424
|
+
* | 入口 | 类型 |
|
|
425
|
+
* | --- | --- |
|
|
426
|
+
* | `.mjs` / `.mts` | `esm` |
|
|
427
|
+
* | `.cjs` / `.cts` | `cjs` |
|
|
428
|
+
* | `.js` + `type: "module"` | `esm` |
|
|
429
|
+
* | `.js` + `module` 字段指向它 | `esm`(打包器约定) |
|
|
430
|
+
* | 其余 `.js` | `cjs` |
|
|
431
|
+
* | `.ts` / `.tsx` / `.jsx` | `source`(转译面,原样) |
|
|
432
|
+
* | `.css` | `style` |
|
|
433
|
+
* | 其他(图片 / 字体 / `.json` …) | `asset` |
|
|
434
|
+
*
|
|
435
|
+
* 这是 `exports` 条件(`import` / `require`)选出入口之后的**第二步**:条件决定"哪个文件",这里决定
|
|
436
|
+
* "那个文件是什么模块"。只有 `cjs` 需要导出面适配,`esm` / `source` 一律原样。
|
|
437
|
+
*
|
|
438
|
+
* @param entry - 入口的绝对路径。
|
|
439
|
+
* @returns 模块类型。
|
|
440
|
+
*/
|
|
441
|
+
export async function moduleKindOf(entry: string): Promise<ModuleKind> {
|
|
442
|
+
const extension = extname(entry).toLowerCase();
|
|
443
|
+
if (extension === ".css") return "style";
|
|
444
|
+
if (extension === ".mjs" || extension === ".mts") return "esm";
|
|
445
|
+
if (extension === ".cjs" || extension === ".cts") return "cjs";
|
|
446
|
+
if (extension === ".ts" || extension === ".tsx" || extension === ".jsx") return "source";
|
|
447
|
+
if (extension !== ".js") return "asset";
|
|
448
|
+
const manifestPath = await packageJsonNear(entry);
|
|
449
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8").catch(() => "{}")) as {
|
|
450
|
+
type?: string;
|
|
451
|
+
};
|
|
452
|
+
if (manifest.type === "module") return "esm";
|
|
453
|
+
if (manifest.type === "commonjs") return "cjs";
|
|
454
|
+
// 没有 `type`:Node 的规则是**语法检测**(`--experimental-detect-module`,Node 23+ 默认开启)。
|
|
455
|
+
return (await hasModuleSyntax(entry)) ? "esm" : "cjs";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* CJS 入口的**具名导出包装**(`react` / `@xterm/xterm` 这类)。
|
|
327
460
|
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
461
|
+
* rolldown 对 CJS 入口的产物的具名导出只能靠静态分析;`module.exports = require(…)` 与 UMD 包装它都分析
|
|
462
|
+
* 不出来,产物只剩 `export default …`——而浏览器侧要 `import { Fragment } from "react"`、`import { Terminal }
|
|
463
|
+
* from "@xterm/xterm"`,整个 import 阶段就失败。ESM 的导出必须**静态可枚举**,所以这里求值入口拿到它的键,
|
|
464
|
+
* 生成一份把它们写成 `export const` 的包装文件,拿包装文件当入口。
|
|
332
465
|
*
|
|
333
|
-
* @
|
|
466
|
+
* 类型由 {@link moduleKindOf} 先按 **exports 落点 + 扩展名/type** 静态判定,只有 CJS 才进来;
|
|
467
|
+
* 这里求值一次只为**拿键**(`export const <键>` + `default = module.exports`)。ESM 原封不动。
|
|
468
|
+
*
|
|
469
|
+
* @returns 包装文件路径;入口不是 node 能求值的 CJS、或没有可静态导出的键时 undefined(保持原样)。
|
|
334
470
|
*/
|
|
335
471
|
async function cjsShimOf(options: {
|
|
336
472
|
name: string;
|
|
337
473
|
exportName: string;
|
|
474
|
+
/** 具体文件:只用来求值判 CJS / 枚举键。 */
|
|
338
475
|
entry: string;
|
|
339
|
-
|
|
476
|
+
/** 产物里的 import 来源:说明符(交给 rolldown 按 `exports` 解析)或文件路径。 */
|
|
477
|
+
from: string;
|
|
340
478
|
log?: (message: string) => void;
|
|
341
|
-
}): Promise<string | undefined> {
|
|
342
|
-
|
|
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[];
|
|
479
|
+
}): Promise<{ id: string; code: string } | undefined> {
|
|
480
|
+
let loaded: Record<string, unknown> | undefined;
|
|
351
481
|
try {
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
| undefined;
|
|
356
|
-
keys = Object.keys(loaded ?? {});
|
|
482
|
+
// 类型已由 {@link moduleKindOf} 静态判定(只有 CJS 会走到这里);这一句只为**拿键**:
|
|
483
|
+
// CJS 的导出面是运行期的(`module.exports = require(…)` / UMD 包装,静态分析看不到键)。
|
|
484
|
+
loaded = createRequire(options.entry)(options.entry) as Record<string, unknown> | undefined;
|
|
357
485
|
} catch {
|
|
358
486
|
// 求值有副作用、或本来就是不能在 node 里加载的东西:宁可只有 default 导出,也不要让构建挂掉。
|
|
359
487
|
return undefined;
|
|
360
488
|
}
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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",
|
|
489
|
+
const keys = Object.keys(loaded ?? {});
|
|
490
|
+
const named = keys.filter(
|
|
491
|
+
(key) =>
|
|
492
|
+
key !== "default" &&
|
|
493
|
+
/^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key) &&
|
|
494
|
+
!RESERVED_EXPORT_NAMES.has(key),
|
|
377
495
|
);
|
|
496
|
+
if (named.length === 0) return undefined;
|
|
497
|
+
|
|
498
|
+
// **不落盘**:这是一份只喂给 rolldown 的入口,用虚拟模块(`\0` 前缀 + resolveId/load)即可。
|
|
499
|
+
// 落成文件反而要操心"放哪、相对路径怎么算、缓存目录里会不会残留"——入口用绝对路径,rolldown 在
|
|
500
|
+
// **构建期**把它读进来(内联进产物),产物里不会出现这个路径。
|
|
501
|
+
const id = `\0dsh-cjs-shim:${options.name}:${options.exportName}`;
|
|
502
|
+
const code = [
|
|
503
|
+
`import mod from ${JSON.stringify(options.from)};`,
|
|
504
|
+
// 用**计算属性**读:`mod.jsxDEV` 这种静态属性访问会被 rolldown 按它对 CJS 命名空间的静态理解
|
|
505
|
+
// 摇成 `void 0`(`react/jsx-dev-runtime` 就那么被摇空了,页面报 `jsxDEV is not a function`)。
|
|
506
|
+
"const __cjs = mod ?? {};",
|
|
507
|
+
"export default __cjs;",
|
|
508
|
+
...named.map((key) => `export const ${key} = __cjs[${JSON.stringify(key)}];`),
|
|
509
|
+
"",
|
|
510
|
+
].join("\n");
|
|
378
511
|
options.log?.(
|
|
379
512
|
`[bundle-cjs] ${options.name}/${options.exportName}: 补 ${String(named.length)} 个具名导出\n`,
|
|
380
513
|
);
|
|
381
|
-
return
|
|
514
|
+
return { id, code };
|
|
382
515
|
}
|
|
383
516
|
|
|
384
517
|
/**
|
|
385
|
-
* CSS
|
|
386
|
-
*
|
|
387
|
-
*
|
|
518
|
+
* CSS 出口的产物:**就是 CSS 文本**(不包装成 JS、不注入 `<style>`)。
|
|
519
|
+
*
|
|
520
|
+
* JS 里的 `import "x.css"` 是另一条路(`cssInlinePlugins` 在构建期内联成注入样式的模块),与本函数无关。
|
|
521
|
+
* 这里的 `url()` 指向包内资产:读到字节、按内容 hash 落到 `assets/`、改写成联邦地址——否则浏览器会按
|
|
522
|
+
* CSS 的相对路径去请求 `.pnpm` 里的文件,路由按清单服务就是 404。
|
|
388
523
|
*
|
|
389
|
-
*
|
|
524
|
+
* `lightningcss` 的 visitor 是同步回调,所以分两遍:第一遍只收集相对 `url()`,异步读资产/算 hash;
|
|
525
|
+
* 第二遍按映射改写(全程异步 IO)。
|
|
526
|
+
*
|
|
527
|
+
* @param options - 入口文件、出口名、缓存目录、包键、产物清单。
|
|
390
528
|
*/
|
|
391
|
-
async function
|
|
392
|
-
exportName: string;
|
|
529
|
+
async function bundleStyleOf(options: {
|
|
393
530
|
entry: string;
|
|
531
|
+
exportName: string;
|
|
394
532
|
outDir: string;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
533
|
+
key: string;
|
|
534
|
+
files: Record<string, string>;
|
|
535
|
+
}): Promise<void> {
|
|
536
|
+
const { transform } = await import("lightningcss");
|
|
537
|
+
const source = await readFile(options.entry);
|
|
538
|
+
const relativeUrl = (ref: string): boolean =>
|
|
539
|
+
ref !== "" &&
|
|
540
|
+
!ref.startsWith("#") &&
|
|
541
|
+
!ref.startsWith("/") &&
|
|
542
|
+
!/^[a-z][a-z0-9+.-]*:/iu.test(ref);
|
|
543
|
+
|
|
544
|
+
const refs = new Set<string>();
|
|
545
|
+
transform({
|
|
546
|
+
filename: options.entry,
|
|
547
|
+
code: source,
|
|
548
|
+
minify: false,
|
|
549
|
+
visitor: {
|
|
550
|
+
Url(node) {
|
|
551
|
+
if (relativeUrl(node.url)) refs.add(node.url);
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const mapping = new Map<string, string>();
|
|
557
|
+
for (const ref of refs) {
|
|
558
|
+
const absolute = join(dirname(options.entry), ref);
|
|
559
|
+
const bytes = await readFile(absolute).catch(() => undefined);
|
|
560
|
+
if (bytes === undefined) continue;
|
|
561
|
+
const extension = extname(absolute);
|
|
562
|
+
const name = `assets/${basename(absolute, extension)}-${createHash("sha256")
|
|
563
|
+
.update(bytes)
|
|
564
|
+
.digest("hex")
|
|
565
|
+
.slice(0, 8)}${extension}`;
|
|
566
|
+
mapping.set(ref, `${CLIENT_MODULES_PREFIX}/${options.key}/${name}`);
|
|
567
|
+
await mkdir(dirname(join(options.outDir, name)), { recursive: true });
|
|
568
|
+
await writeFile(join(options.outDir, name), bytes);
|
|
569
|
+
options.files[name] = `${options.key}/${name}`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const result = transform({
|
|
573
|
+
filename: options.entry,
|
|
574
|
+
code: source,
|
|
575
|
+
minify: false,
|
|
576
|
+
visitor: {
|
|
577
|
+
Url(node) {
|
|
578
|
+
const rewritten = mapping.get(node.url);
|
|
579
|
+
return rewritten === undefined ? undefined : { ...node, url: rewritten };
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
const file = join(options.outDir, options.exportName);
|
|
398
584
|
await mkdir(dirname(file), { recursive: true });
|
|
399
|
-
await writeFile(file,
|
|
400
|
-
|
|
585
|
+
await writeFile(file, result.code);
|
|
586
|
+
options.files[options.exportName] = `${options.key}/${options.exportName}`;
|
|
401
587
|
}
|
|
402
588
|
|
|
403
589
|
/**
|
|
@@ -507,7 +693,13 @@ async function bundlePackageOnce(
|
|
|
507
693
|
const outDir = join(options.cacheRoot, key);
|
|
508
694
|
const manifestPath = join(outDir, ".files.json");
|
|
509
695
|
options.log?.(`[bundle-rev] ${options.name}: hash-start watch=${options.watch.join(",")}\n`);
|
|
510
|
-
|
|
696
|
+
// **包根路径**也进指纹(与出口无关,同一包的各个出口共享):入口是包根下的相对位置,包跟着 app
|
|
697
|
+
// 换目录后路径变了,只算内容就会复用上一套产物——CJS shim 里的相对路径指向的入口已经不在了。
|
|
698
|
+
const rootRev = createHash("sha1")
|
|
699
|
+
.update(dirname(options.watch[0] ?? options.manifestOf ?? ""))
|
|
700
|
+
.digest("hex")
|
|
701
|
+
.slice(0, 8);
|
|
702
|
+
const rev = `${BUNDLER_REV}:${rootRev}:${await fingerprint(options.watch)}`;
|
|
511
703
|
options.log?.(`[bundle-rev] ${options.name}: hash=${rev}\n`);
|
|
512
704
|
const cached = JSON.parse(await readFile(manifestPath, "utf8").catch(() => "{}")) as {
|
|
513
705
|
rev?: string;
|
|
@@ -537,7 +729,24 @@ async function bundlePackageOnce(
|
|
|
537
729
|
return undefined;
|
|
538
730
|
},
|
|
539
731
|
},
|
|
540
|
-
browserProcessPlugin(),
|
|
732
|
+
browserProcessPlugin(await clientBuildMeta(options.repoRoot)),
|
|
733
|
+
// 裸说明符的样式引用(`katex/dist/katex.min.css`)先解析成真实文件:否则外置插件会把它当
|
|
734
|
+
// 包边界、改写成联邦 URL,浏览器按 ESM import 一个 `text/css` 响应直接报 MIME 错误。
|
|
735
|
+
// 解析成文件后由 `cssInlinePlugins` 在**引用方**产物里内联(JS 里的 CSS import 行为不变)。
|
|
736
|
+
{
|
|
737
|
+
name: "dsh-css-specifiers",
|
|
738
|
+
resolveId(source: string, importer: string | undefined): string | undefined {
|
|
739
|
+
if (!source.endsWith(".css") || source.startsWith(".") || source.startsWith("/")) {
|
|
740
|
+
return undefined;
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
return createRequire(importer ?? join(options.repoRoot, "package.json")).resolve(source);
|
|
744
|
+
} catch {
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
|
|
541
750
|
cssInlinePlugins({
|
|
542
751
|
name: key,
|
|
543
752
|
assets: "threshold",
|
|
@@ -562,34 +771,72 @@ async function bundlePackageOnce(
|
|
|
562
771
|
esmExternalRequirePlugin({
|
|
563
772
|
// **外置名单 = 这条链认识的那些包**(包名 → 版本表的键),node 内置除外(它们走替身)。
|
|
564
773
|
// 不是包的(相对路径、内置、虚拟模块)留在产物里内联。
|
|
774
|
+
// 入口就是本包:它不能被外置成自己的 URL(否则入口解析会拿到 external,构建直接失败)。
|
|
565
775
|
external: [...resolvable.keys()]
|
|
566
|
-
.filter((name) => !NODE_BUILTIN.test(name))
|
|
776
|
+
.filter((name) => !NODE_BUILTIN.test(name) && name !== options.name)
|
|
567
777
|
.map((name) => barePackageRegExp(name)),
|
|
568
778
|
}),
|
|
569
779
|
];
|
|
570
780
|
const startedAt = Date.now();
|
|
571
781
|
options.log?.(`[bundle-build] ${options.name}: rolldown start\n`);
|
|
572
|
-
//
|
|
573
|
-
//
|
|
782
|
+
// 入口:调用方已经按清单解析好(`exports` 条件 / `module` / `main`),这里按模块类型决定直通 / CJS 包装 /
|
|
783
|
+
// CSS 产物。CSS 用虚拟空模块占位(rolldown 的入口管线只吃 JS 文件名),产物在构建后由 `bundleStyleOf` 写。
|
|
574
784
|
const input: Record<string, string> = {};
|
|
785
|
+
const entryShims = new Map<string, string>();
|
|
786
|
+
const styleEntries = new Map<string, string>();
|
|
575
787
|
for (const [exportName, entry] of Object.entries(options.input)) {
|
|
576
|
-
|
|
577
|
-
|
|
788
|
+
const kind = await moduleKindOf(entry);
|
|
789
|
+
if (kind === "cjs") {
|
|
790
|
+
const shim = await cjsShimOf({
|
|
578
791
|
name: options.name,
|
|
579
792
|
exportName,
|
|
580
793
|
entry,
|
|
794
|
+
from: entry,
|
|
581
795
|
...(options.log === undefined ? {} : { log: options.log }),
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
796
|
+
});
|
|
797
|
+
if (shim !== undefined) entryShims.set(shim.id, shim.code);
|
|
798
|
+
input[exportName] = shim?.id ?? entry;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (kind === "style") {
|
|
802
|
+
styleEntries.set(exportName, entry);
|
|
803
|
+
const styleId = `\0dsh-style:${options.name}:${exportName}:entry`;
|
|
804
|
+
entryShims.set(styleId, "export {};\n");
|
|
805
|
+
input[exportName] = styleId;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
input[exportName] = entry;
|
|
809
|
+
}
|
|
810
|
+
if (entryShims.size !== 0) {
|
|
811
|
+
plugins.push({
|
|
812
|
+
name: "dsh-entry-shims",
|
|
813
|
+
resolveId(source: string): string | undefined {
|
|
814
|
+
return entryShims.has(source) ? source : undefined;
|
|
815
|
+
},
|
|
816
|
+
load(source: string): string | undefined {
|
|
817
|
+
return entryShims.get(source);
|
|
818
|
+
},
|
|
819
|
+
});
|
|
586
820
|
}
|
|
821
|
+
|
|
587
822
|
// **直接跑 rolldown**(不经过 `vite build`):插件本来就是 rolldown 的接口,而 vite 在这条路上只提供了
|
|
588
823
|
// lib 模式的入口/输出命名与 `worker.format`——那几项自己写更直接,且 `build.rolldownOptions` 里的
|
|
589
824
|
// `transform` / `resolve` 也不会再被 vite 吞掉(对照表见债文档,那份债随这次改动一起销)。
|
|
590
825
|
const bundle = await rolldown({
|
|
591
826
|
input,
|
|
592
827
|
plugins,
|
|
828
|
+
// 装饰器口径在两处声明(tsconfig 的 `experimentalDecorators` 与 `transform.decorator`)时,rolldown 会提
|
|
829
|
+
// 一条 `CONFIGURATION_FIELD_CONFLICT` 让人确认——那是"重复声明"级提示,两者语义等价,我们主动取
|
|
830
|
+
// transform 这一侧(见上面的 decorator 注释)。过滤掉这一条,别的日志照旧走默认处理。
|
|
831
|
+
onLog: (level, log, handler) => {
|
|
832
|
+
if (
|
|
833
|
+
log.code === "CONFIGURATION_FIELD_CONFLICT" &&
|
|
834
|
+
log.message?.includes("experimentalDecorators") === true
|
|
835
|
+
) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
handler(level, log);
|
|
839
|
+
},
|
|
593
840
|
// **不指 tsconfig**:装饰器由下面的 `transform.decorator.legacy` 直接开(直出时它生效,见对照表)。
|
|
594
841
|
// rolldown 自己会解析 tsconfig(含 `extends`,实测过),所以两种形态各指一处不如一处都不指。
|
|
595
842
|
...(options.tsconfig === undefined ? {} : { resolve: { tsconfigFilename: options.tsconfig } }),
|
|
@@ -598,12 +845,16 @@ async function bundlePackageOnce(
|
|
|
598
845
|
// 直出后没有 `build.target: "baseline-widely-available"` 那层展开,这里自己写全。不要图省事写
|
|
599
846
|
// `esnext` 或留空——目标一低,oxc 就给类字段、可选链这些逐个加降级 helper,产物里全是奇怪的垫片。
|
|
600
847
|
target: ["chrome111", "edge111", "firefox114", "safari16.4"],
|
|
601
|
-
// 装饰器:legacy(cordis
|
|
848
|
+
// 装饰器:legacy(cordis 与上游用的就是这套)。**必须显式给**:闭包面(`node_modules`)里的包不在
|
|
849
|
+
// tsconfig 的 include 面里(tsc 默认排除 `node_modules`,oxc 同样不应用那份设置),只靠根 tsconfig 的
|
|
850
|
+
// `experimentalDecorators` 的话,上游的 `@Remote` 会原样留在产物里、浏览器整条入口不执行。
|
|
851
|
+
// 它与 tsconfig 那项**语义相同**,两处都声明时 rolldown 会提示"tsconfig 被 transform 覆盖"——这条提示
|
|
852
|
+
// 对我们没有决策价值(见下面的 `onLog`),不是需要处理的冲突。
|
|
602
853
|
decorator: { legacy: true },
|
|
603
|
-
// 浏览器没有 `process
|
|
604
|
-
//
|
|
854
|
+
// 浏览器没有 `process`:裸 `process` 给一个空壳——`immer` 这类包会在模块顶层读它,漏掉就是一个
|
|
855
|
+
// `process is not defined` 把整份产物带下去。`process.env` 的替换在 `browserProcessPlugin` 里做
|
|
856
|
+
// (版本元数据要先于"整个 process.env 变空壳"那一步换成真值,顺序写在一处而不是两处)。
|
|
605
857
|
define: {
|
|
606
|
-
"process.env": "{ env: {} }",
|
|
607
858
|
process: "{ env: {} }",
|
|
608
859
|
},
|
|
609
860
|
},
|
|
@@ -642,8 +893,11 @@ async function bundlePackageOnce(
|
|
|
642
893
|
// 目录会把另一个进程刚写的产物一起带走,表现为偶发 404。产物名本身带语义(出口名 / 资产 hash),直接
|
|
643
894
|
// 覆盖即可;旧的字节留在盘上不碍事。
|
|
644
895
|
await mkdir(outDir, { recursive: true });
|
|
896
|
+
const styleExports = new Set(styleEntries.keys());
|
|
645
897
|
for (const item of items) {
|
|
646
898
|
if (item.type !== "chunk") continue;
|
|
899
|
+
// CSS 出口的 JS 占位不落盘:它只是让 rolldown 完成入口解析。
|
|
900
|
+
if (item.isEntry && styleExports.has(item.name)) continue;
|
|
647
901
|
// 出口名可以含 `/`(子路径出口:`helpers/decorate`),落盘要先把父目录建出来。
|
|
648
902
|
await mkdir(dirname(join(outDir, item.fileName)), { recursive: true });
|
|
649
903
|
await writeFile(join(outDir, item.fileName), item.code, "utf8");
|
|
@@ -660,6 +914,9 @@ async function bundlePackageOnce(
|
|
|
660
914
|
await writeFile(join(outDir, asset.fileName), asset.source ?? "");
|
|
661
915
|
files[asset.fileName] = `${key}/${asset.fileName}`;
|
|
662
916
|
}
|
|
917
|
+
for (const [exportName, entry] of styleEntries) {
|
|
918
|
+
await bundleStyleOf({ entry, exportName, outDir, key, files });
|
|
919
|
+
}
|
|
663
920
|
await writeFile(manifestPath, `${JSON.stringify({ rev, files }, null, 2)}\n`, "utf8");
|
|
664
921
|
// 指纹进地址用的表({@link revs}):**每次构建后更新**——同一会话里后构建的产物引用它时就能带上
|
|
665
922
|
// 当前指纹(浏览器于是可以长缓存那一份)。
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器构建里烘焙的版本元数据。
|
|
3
|
+
*
|
|
4
|
+
* 上游的 client 构建(`scripts/client-build-environment.ts`)把一组 `DSH_CLIENT_*` 值当 `define` 注入产物:
|
|
5
|
+
* - `DSH_CLIENT_VERSION`:设置页「当前版本」那一行;
|
|
6
|
+
* - `DSH_CLIENT_COMMIT_HASH` + `DSH_CLIENT_GIT_DIRTY`:侧栏显示的 `<commit>` / `<commit>-dirty`;
|
|
7
|
+
* - `DSH_CLIENT_BUILD_PROFILE`:`official` / `local`(官方品牌只在 official 下出现)。
|
|
8
|
+
*
|
|
9
|
+
* 这条链以前把 `process.env` 整个替换成 `{ env: {} }`,于是这些值全是 `undefined`:版本行不显示、
|
|
10
|
+
* `ui-settings-account` 还会因为 `DSH_CLIENT_VERSION` 缺失直接抛错。取值顺序(越靠前越权威):
|
|
11
|
+
*
|
|
12
|
+
* 1. 宿主进程的 `DSH_CLIENT_*`(`dshdev` 与桌面壳在起宿主时注入);
|
|
13
|
+
* 2. 仓库根的 `.dsh-build/client-build-environment.json`(`just prepare` 里的 `dshdev client-meta` 写);
|
|
14
|
+
* 3. 现算:仓库根的 `package.json` 版本 + `git rev-parse` / `git status`(dev 常见,代价是几次 git 调用)。
|
|
15
|
+
*/
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { promisify } from "node:util";
|
|
20
|
+
|
|
21
|
+
/** 一份构建元数据;缺项表示该值不可得(前端自己会判 `undefined`)。 */
|
|
22
|
+
export interface ClientBuildMeta {
|
|
23
|
+
readonly version?: string;
|
|
24
|
+
readonly commit?: string;
|
|
25
|
+
readonly dirty?: boolean;
|
|
26
|
+
/** `DSH_CLIENT_BUILD_PROFILE`:`official` / `local`,由上游的构建环境定义,缺了就不注入。 */
|
|
27
|
+
readonly profile?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `prepare` 写、构建读的那份记录(形状与上游 `client-build-environment.json` 一致)。 */
|
|
31
|
+
export const CLIENT_BUILD_RECORD = join(".dsh-build", "client-build-environment.json");
|
|
32
|
+
|
|
33
|
+
const VERSION = "DSH_CLIENT_VERSION";
|
|
34
|
+
const COMMIT = "DSH_CLIENT_COMMIT_HASH";
|
|
35
|
+
const DIRTY = "DSH_CLIENT_GIT_DIRTY";
|
|
36
|
+
const PROFILE = "DSH_CLIENT_BUILD_PROFILE";
|
|
37
|
+
|
|
38
|
+
/** 环境里那组 `DSH_CLIENT_*` → 元数据。 */
|
|
39
|
+
function metaOfEnvironment(environment: NodeJS.ProcessEnv): ClientBuildMeta {
|
|
40
|
+
const version = environment[VERSION];
|
|
41
|
+
const commit = environment[COMMIT];
|
|
42
|
+
const dirty = environment[DIRTY];
|
|
43
|
+
const profile = environment[PROFILE];
|
|
44
|
+
return {
|
|
45
|
+
...(version === undefined || version === "" ? {} : { version }),
|
|
46
|
+
...(commit === undefined || commit === "" ? {} : { commit }),
|
|
47
|
+
...(dirty === "true" ? { dirty: true } : {}),
|
|
48
|
+
...(profile === undefined || profile === "" ? {} : { profile }),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const runFile = promisify(execFile);
|
|
53
|
+
|
|
54
|
+
/** `git` 的输出;不在 git 工作树里、或 git 不可用时 undefined。 */
|
|
55
|
+
async function gitOutput(root: string, args: readonly string[]): Promise<string | undefined> {
|
|
56
|
+
try {
|
|
57
|
+
const { stdout } = await runFile("git", args, { cwd: root, encoding: "utf8" });
|
|
58
|
+
return stdout.trim();
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 现算:**装的是哪一版 harness**(`DEEPSEEK_HARNESS_VERSION`,与同步链同一个真源)+ 本仓库的 git 元数据。
|
|
66
|
+
*
|
|
67
|
+
* 正常路径拿不到这里:`dshdev` 会注入环境、`prepare` 会写记录。只有"绕过 dshdev 直接构建 client 产物"
|
|
68
|
+
* 时才走到,所以版本只能取环境或仓库根的 `package.json`(后者不是权威值,缺 env 时用它兜底)。
|
|
69
|
+
*/
|
|
70
|
+
async function computeMeta(repoRoot: string): Promise<ClientBuildMeta> {
|
|
71
|
+
const explicit = process.env["DEEPSEEK_HARNESS_VERSION"];
|
|
72
|
+
let version: string | undefined = explicit === undefined || explicit === "" ? undefined : explicit;
|
|
73
|
+
if (version === undefined) {
|
|
74
|
+
const manifest = await readFile(join(repoRoot, "package.json"), "utf8").catch(() => undefined);
|
|
75
|
+
if (manifest !== undefined) {
|
|
76
|
+
const parsed = JSON.parse(manifest) as { version?: unknown };
|
|
77
|
+
if (typeof parsed.version === "string" && parsed.version !== "") version = parsed.version;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const commit = await gitOutput(repoRoot, ["rev-parse", "--short=7", "HEAD"]);
|
|
81
|
+
const status = await gitOutput(repoRoot, ["status", "--porcelain", "--untracked-files=normal"]);
|
|
82
|
+
return {
|
|
83
|
+
...(version === undefined ? {} : { version }),
|
|
84
|
+
...(commit === undefined ? {} : { commit }),
|
|
85
|
+
...(status === undefined || status === "" ? {} : { dirty: true }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 读 `prepare` 写下的那份记录(缺失 / 坏掉都给 undefined,不挡构建)。 */
|
|
90
|
+
async function recordedMeta(repoRoot: string): Promise<ClientBuildMeta | undefined> {
|
|
91
|
+
const raw = await readFile(join(repoRoot, CLIENT_BUILD_RECORD), "utf8").catch(() => undefined);
|
|
92
|
+
if (raw === undefined) return undefined;
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(raw) as { environment?: Record<string, string> };
|
|
95
|
+
const environment = parsed.environment ?? {};
|
|
96
|
+
return metaOfEnvironment(environment as NodeJS.ProcessEnv);
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 这个仓库在浏览器产物里该带的版本元数据。
|
|
104
|
+
* @param repoRoot - 仓库根(dev 是工作区,打包形态是 profile:那里通常读不到记录,靠宿主注入的环境)。
|
|
105
|
+
*/
|
|
106
|
+
export async function clientBuildMeta(repoRoot: string): Promise<ClientBuildMeta> {
|
|
107
|
+
const inherited = metaOfEnvironment(process.env);
|
|
108
|
+
if (inherited.version !== undefined && inherited.commit !== undefined) return inherited;
|
|
109
|
+
const recorded = await recordedMeta(repoRoot);
|
|
110
|
+
const computed = recorded ?? (await computeMeta(repoRoot));
|
|
111
|
+
return { ...computed, ...inherited };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `define` 用的一组键:`process.env.DSH_CLIENT_*` → 字面量。
|
|
116
|
+
*
|
|
117
|
+
* `browserProcessPlugin` 会跳过这些键(见那里的负向前瞻),所以 define 这一层能把它们换成真实值;
|
|
118
|
+
* 其余 `process.env` 仍是空壳对象。
|
|
119
|
+
*/
|
|
120
|
+
export function clientMetaDefines(meta: ClientBuildMeta): Record<string, string> {
|
|
121
|
+
return {
|
|
122
|
+
[`process.env.${VERSION}`]: meta.version === undefined ? "undefined" : JSON.stringify(meta.version),
|
|
123
|
+
[`process.env.${COMMIT}`]: meta.commit === undefined ? "undefined" : JSON.stringify(meta.commit),
|
|
124
|
+
[`process.env.${DIRTY}`]: meta.dirty === true ? JSON.stringify("true") : "undefined",
|
|
125
|
+
// 官方品牌(`ui-brand-official`)按这个值判断:值也来自上游的构建环境,缺了就不注入。
|
|
126
|
+
[`process.env.${PROFILE}`]: meta.profile === undefined ? "undefined" : JSON.stringify(meta.profile),
|
|
127
|
+
};
|
|
128
|
+
}
|