@oj-module/cli 0.2.56 → 0.2.57

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.
Files changed (36) hide show
  1. package/devkit/README.md +7 -4
  2. package/devkit/SKILL.md +4 -1
  3. package/devkit/manual.md +4 -2
  4. package/devkit/module-dev-and-cdn-playground.md +7 -0
  5. package/devkit/publish-walkthrough.md +199 -0
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +5 -5
  8. package/shell-dist/index.html +3 -3
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/vendor-devkit.test.ts +87 -0
  11. package/src/build/backend.ts +131 -0
  12. package/src/build/module-build.ts +364 -0
  13. package/src/build/module-def.ts +381 -0
  14. package/src/build/site-merge.ts +33 -0
  15. package/src/build.ts +16 -885
  16. package/src/bundle.ts +61 -0
  17. package/src/cli.ts +4 -1
  18. package/src/init.ts +12 -35
  19. package/src/lifecycle/__tests__/bundle-summary.test.ts +106 -0
  20. package/src/lifecycle/__tests__/bundle.test.ts +6 -0
  21. package/src/vendor.ts +34 -0
  22. package/templates/openapi.json +7 -0
  23. package/templates/src/app/api/_platform/_lifecycle/installer.ts +8 -3
  24. package/templates/src/app/api/_platform/_lifecycle/steps/register.ts +53 -0
  25. package/templates/src/app/api/_platform/_shared/service/domain/roles.ts +4 -1
  26. package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +7 -3
  27. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +3 -0
  28. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +3 -0
  29. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +8 -0
  30. package/templates/src/app/api/_platform/schema.yaml +2 -0
  31. package/templates/src/app/api/system/roles/api.ts +2 -1
  32. package/templates/src/app/web/system/__client/api.schemas.ts +1 -0
  33. package/templates/src/app/web/system/pages/roles/index.tsx +7 -1
  34. package/templates/tests/lifecycle/role-title-translate.spec.ts +171 -0
  35. package/templates/tests/rbac-roles.spec.ts +9 -0
  36. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,364 @@
1
+ // 前端模块构建(SRP 拆分自 build.ts,2026-11-03,计划 docs/plans/20261103-0930):
2
+ // 逐模块 vite/rolldown 构建 → 产物完整性(sha384 chunks、importmap 说明符校验、
3
+ // CJS require 门禁)→ modules.json 清单。元数据读取见 module-def.ts,
4
+ // 后端编排见 backend.ts。
5
+ import type { Plugin } from "vite";
6
+ import type { ProjectLayout } from "../layout";
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import tailwindcss from "@tailwindcss/vite";
11
+ import { build } from "vite";
12
+ import { parse as parseYaml } from "yaml";
13
+ import { assertModuleList, loadSiteConfig, mergeModuleList, resolveModuleEntry } from "../config";
14
+ import { moduleDir, resolveLayout, scanModules } from "../layout";
15
+ import { isPrebuiltDep, isSharedDep, SHARED_DEPS } from "../shared-deps";
16
+ import { checkSharedVersions, resolveShellDist } from "../versions";
17
+ import { readModuleDefinition } from "./module-def";
18
+ import { mergeShellSite } from "./site-merge";
19
+
20
+ /** modules.json 中的 chunk 条目 */
21
+ export interface ChunkEntry {
22
+ url: string
23
+ integrity: string
24
+ /** 懒加载 chunk:L2 完整性档位下不受保护(设计文档 §4.7) */
25
+ lazy: boolean
26
+ }
27
+
28
+ /** modules.json 中的模块条目 */
29
+ export interface BuiltModule {
30
+ name: string
31
+ version: string
32
+ enabled: boolean
33
+ dependencies: string[]
34
+ peerRuntime?: string
35
+ entry: string
36
+ integrity: string
37
+ css: string[]
38
+ chunks: ChunkEntry[]
39
+ /**
40
+ * 生效的 web 路由前缀段(契约 C2-14:`web.route_prefix` 缺省 `/<name>`)。
41
+ * 运行期据其改写模块路由根(单根约定下仅根段),缺省 = name 保证零漂移。
42
+ */
43
+ routePrefix: string
44
+ }
45
+
46
+ /**
47
+ * 解析模块生效的 web 路由前缀段(R1.1)。
48
+ * module-root:读 `src/<name>/solution.yaml` 的 `web.route_prefix`(契约 C2-14,缺省 `/<name>`);
49
+ * 未配置/非法/非 module-root → 缺省 `name`,与现有路由根(顶层段即 name)逐字节一致。
50
+ */
51
+ function resolveRoutePrefix(layout: ProjectLayout, name: string): string {
52
+ if (layout.kind !== "module-root")
53
+ return name;
54
+ const solPath = path.join(moduleDir(layout, name), "solution.yaml");
55
+ if (!fs.existsSync(solPath))
56
+ return name;
57
+ try {
58
+ const doc = parseYaml(fs.readFileSync(solPath, "utf-8")) as Record<string, unknown>;
59
+ const web = doc.web;
60
+ if (web && typeof web === "object" && !Array.isArray(web)) {
61
+ const rp = (web as Record<string, unknown>).route_prefix;
62
+ if (typeof rp === "string" && /^\/[a-z][a-z0-9-]{0,63}$/.test(rp))
63
+ return rp.slice(1);
64
+ }
65
+ }
66
+ catch {
67
+ // YAML 解析失败 → 缺省,不阻断构建(契约校验会在别处暴露)
68
+ }
69
+ return name;
70
+ }
71
+
72
+ const ALGORITHM = "sha384";
73
+
74
+ function sha384(file: string): string {
75
+ const digest = crypto.createHash(ALGORITHM).update(fs.readFileSync(file)).digest("base64");
76
+ return `${ALGORITHM}-${digest}`;
77
+ }
78
+
79
+ function warnUnsharedDeps(projectRoot: string) {
80
+ const pkgPath = path.join(projectRoot, "package.json");
81
+ if (!fs.existsSync(pkgPath))
82
+ return;
83
+
84
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
85
+ const deps = Object.keys(pkg.dependencies ?? {});
86
+ const unshared = deps.filter(dep => !isSharedDep(dep) && !dep.startsWith("@oj-module/"));
87
+
88
+ if (unshared.length) {
89
+ console.warn(
90
+ `[ojm] ⚠️ 以下运行时依赖不在共享表内,会被打进模块产物:${unshared.join(", ")}\n`
91
+ + " 若它应与宿主共用,请加入 SHARED_DEPS 并在宿主 importmap 中映射(设计文档 C8)",
92
+ );
93
+ }
94
+ }
95
+
96
+ /** 提取产物文件中的全部裸说明符(排除相对/绝对/# 路径) */
97
+ function collectBareSpecifiers(file: string): string[] {
98
+ const source = fs.readFileSync(file, "utf-8");
99
+ const found = new Set<string>();
100
+ const patterns = [
101
+ /(?:import|export)\s[^'";]*?from\s*["']([^"']+)["']/g, // import/export ... from "x"
102
+ /import\s*["']([^"']+)["']/g, // 副作用导入 import "x"
103
+ /import\(\s*["']([^"']+)["']\s*\)/g, // 动态 import("x")
104
+ ];
105
+ for (const pattern of patterns) {
106
+ for (const match of source.matchAll(pattern)) {
107
+ const spec = match[1]!;
108
+ if (!spec.startsWith(".") && !spec.startsWith("/") && !spec.startsWith("#"))
109
+ found.add(spec);
110
+ }
111
+ }
112
+ return [...found];
113
+ }
114
+
115
+ /**
116
+ * P7.9 / B11 + C8:构建期扫描模块产物的裸说明符。
117
+ * - 深路径命中共享表前缀但非精确 importmap 键(如 dayjs/plugin/utc):
118
+ * 构建期直接报错——importmap 无前缀通配,浏览器必抛
119
+ * "Failed to resolve module specifier",不能拖到运行期(§4.7 错误契约)。
120
+ * - 表外说明符:C8 构建期告警(按真实 import 分析,不依赖 package.json 字段)。
121
+ */
122
+ function assertResolvableSpecifiers(
123
+ moduleName: string,
124
+ moduleOutDir: string,
125
+ emitted: Map<string, { type: string, fileName: string }>,
126
+ ): void {
127
+ const exactKeys = new Set(SHARED_DEPS.map(dep => dep.specifier));
128
+ const deepPathErrors: string[] = [];
129
+ const unsharedWarnings = new Set<string>();
130
+
131
+ for (const info of emitted.values()) {
132
+ if (info.type !== "chunk")
133
+ continue;
134
+ for (const spec of collectBareSpecifiers(path.join(moduleOutDir, info.fileName))) {
135
+ if (exactKeys.has(spec))
136
+ continue;
137
+ if (isSharedDep(spec)) {
138
+ deepPathErrors.push(` · ${info.fileName} → "${spec}"`);
139
+ }
140
+ else {
141
+ unsharedWarnings.add(spec);
142
+ }
143
+ }
144
+ }
145
+
146
+ if (deepPathErrors.length > 0) {
147
+ throw new Error(
148
+ `[ojm] 模块 "${moduleName}" 的产物含 importmap 无法解析的深路径说明符:\n`
149
+ + `${deepPathErrors.join("\n")}\n`
150
+ + "共享表只提供精确键(importmap 无前缀通配)。修复建议:改从包根导入"
151
+ + "(如 dayjs 插件改在宿主侧注册),或联系框架方在 SHARED_DEPS 增补该深路径条目。",
152
+ );
153
+ }
154
+
155
+ if (unsharedWarnings.size > 0) {
156
+ console.warn(
157
+ `[ojm] ⚠️ 模块 "${moduleName}" import 了共享表外的三方库:${[...unsharedWarnings].join(", ")}\n`
158
+ + " 它们会被打进模块产物。若应与宿主共用,请加入 SHARED_DEPS(设计文档 C8)",
159
+ );
160
+ }
161
+ }
162
+
163
+ /**
164
+ * 构建模块工程。
165
+ *
166
+ * 产出(layout.distDir:module-root / new → dist/web;flat → build):
167
+ * <dist>/modules/<name>/<version>/{entry.js, chunk-*.js, *.css}
168
+ * <dist>/modules.json
169
+ *
170
+ * opts.mergeSite 仅 `ojm build` 传 true:清场 + 拷 shell dist 全量;
171
+ * devServer 热重建默认 false,只写模块产物。
172
+ */
173
+ export async function buildModules(
174
+ projectRoot: string,
175
+ opts: { mergeSite?: boolean } = {},
176
+ ): Promise<BuiltModule[]> {
177
+ const layout = resolveLayout(projectRoot);
178
+ const config = await loadSiteConfig(projectRoot);
179
+ // module-root:模块清单由 src/*/web/entry.ts 扫描得出(web.config.ts 仅可选覆盖);
180
+ // 历史布局:仍以 web.config.ts 的 modules 为唯一真源。
181
+ const modules = layout.kind === "module-root"
182
+ ? mergeModuleList(scanModules(layout), config.modules, projectRoot)
183
+ : assertModuleList(projectRoot, config.modules);
184
+ const outDir = layout.distDir;
185
+ const baseUrl = config.baseUrl ?? "";
186
+
187
+ if (opts.mergeSite)
188
+ mergeShellSite(outDir, config.siteBase ?? "", config.apiBase ?? "");
189
+
190
+ // P4.5 / C4 / D12:构建前先过版本矩阵门禁,版本漂移直接拒绝
191
+ checkSharedVersions(projectRoot, resolveShellDist());
192
+
193
+ warnUnsharedDeps(projectRoot);
194
+
195
+ const built: BuiltModule[] = [];
196
+
197
+ for (const item of modules) {
198
+ if (item.enabled === false) {
199
+ console.log(`[ojm] 跳过已禁用模块 ${item.name}`);
200
+ continue;
201
+ }
202
+
203
+ const entryFile = resolveModuleEntry(projectRoot, item.entry);
204
+ const definition = await readModuleDefinition(entryFile, projectRoot);
205
+
206
+ if (definition.name !== item.name) {
207
+ throw new Error(
208
+ `模块名不一致:${layout.kind === "module-root" ? `src/${item.name}` : "web.config.ts"} 中为 "${item.name}",entry.ts 中为 "${definition.name}"`,
209
+ );
210
+ }
211
+
212
+ const moduleOutDir = path.join(outDir, "modules", definition.name, definition.version);
213
+ const emitted = new Map<string, { fileName: string, type: "chunk" | "asset", isEntry: boolean, isDynamicEntry: boolean }>();
214
+
215
+ const collectChunks: Plugin = {
216
+ name: "ojm:collect-chunks",
217
+ writeBundle(_options, bundle) {
218
+ for (const [fileName, output] of Object.entries(bundle)) {
219
+ emitted.set(fileName, {
220
+ fileName,
221
+ type: output.type,
222
+ isEntry: output.type === "chunk" && output.isEntry,
223
+ isDynamicEntry: output.type === "chunk" && output.isDynamicEntry,
224
+ });
225
+ }
226
+ },
227
+ };
228
+
229
+ console.log(`[ojm] 构建 ${definition.name}@${definition.version} ...`);
230
+
231
+ await build({
232
+ root: projectRoot,
233
+ logLevel: "warn",
234
+ // §4.1-④:tailwind 模块管线——模块内 import 的 css 经 @tailwindcss/vite
235
+ // 编译,产物由宿主 <link> 注入(R16);模块无 css 入口时插件不产生任何产物
236
+ plugins: [collectChunks, tailwindcss()],
237
+ // Spike A 坑 2:lib 模式不替换 process.env.NODE_ENV,
238
+ // 浏览器顶层求值会抛 process is not defined(风险 R15)
239
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
240
+ build: {
241
+ outDir: moduleOutDir,
242
+ emptyOutDir: true,
243
+ sourcemap: false,
244
+ /**
245
+ * 模块产物压缩(2026-09-17 由 `false` 改为 `true`)。
246
+ *
247
+ * 本行原为 `false` **且无注释**(同文件其余设置都带 rationale)—— 经查出自
248
+ * 2026-08-31 的调试提交 `1bf3200`,疑似调试期临时值遗留。实测代价极大:
249
+ * 仅这一项就让 planning 的 gzip 从 186,730 → **75,872(−59.4%)**、
250
+ * workitems 88,351 → **40,113(−54.6%)**(同一份产物只做压缩,未改任何配置)。
251
+ * 背景:曾评估「把重复的生成 client 提成共享资产来省体积」,实测 gzip 净变化
252
+ * 仅 −364 B(生成 client 压缩率 6.8%,且重复内容已被同一 bundle 内的 gzip
253
+ * 滑窗吃掉)⇒ **真正的杠杆是压缩,不是去重**(见 oj-module 台账 T54)。
254
+ *
255
+ * ⚠️ **本项只作用于「模块产物」**,与宿主共享资产无关:`shell-dist/assets/*.js`
256
+ * 由 `shell/scripts/build.mts` 自己的 esbuild 构建,且 `esm-exports.ts` 的
257
+ * 导出面解析**依赖资产顶格输出**(其文件头写明「`minify: false` 下顶层语句
258
+ * 一律顶格输出」)⇒ **不要**顺手把共享资产也压了,那会让导入完整性门禁失效。
259
+ *
260
+ * 代价(已知并接受):生产产物栈不可读(`sourcemap: false` 保持不变 —— 开
261
+ * sourcemap 会额外部署 .map 文件,属另一个决策)。
262
+ */
263
+ minify: true,
264
+ lib: {
265
+ entry: entryFile,
266
+ formats: ["es"],
267
+ fileName: () => "entry.js",
268
+ },
269
+ rolldownOptions: {
270
+ // 只把共享依赖留给宿主 importmap;
271
+ // 相对路径 / 绝对路径是模块自身代码,需要打包
272
+ external: (id: string) => !id.startsWith(".") && !path.isAbsolute(id) && isPrebuiltDep(id),
273
+ output: {
274
+ // P7.x / e2e:模块产物内联动态 import(lazy 页面、i18n 等),
275
+ // 输出为**单文件**。原因:
276
+ // 1. 拆出的 chunk 只能靠 import.meta.url 在「真实 HTTP 服务」下解析,
277
+ // 任何非 HTTP 加载上下文(e2e harness、离线/原生壳、L2 完整性之外的
278
+ // 加载路径)都会 ECONNREFUSED / 404,导致页面空白;
279
+ // 2. 拆出的 lazy chunk 不受 L2 完整性保护(构建期已告警),是模块化
280
+ // 方案的真实完整性缺陷;单文件后整包受 modulepreload+integrity 覆盖;
281
+ // 3. 单文件模块是微前端模块的通用形态,宿主按真实 URL 一次性加载即可,
282
+ // 无孤儿 chunk 依赖。
283
+ codeSplitting: false as any,
284
+ },
285
+ },
286
+ },
287
+ });
288
+
289
+ // 运行期 require 门禁:CJS 依赖 require 了 external 共享包时,rolldown 只留一个浏览器
290
+ // 不可用的 require 桩("Calling `require` for ..."),表现为模块加载即抛错、页面空白。
291
+ // 这类依赖必须移出 bundle 走宿主 importmap(shell 侧有 CJS→ESM 垫片)。
292
+ const builtEntry = path.join(moduleOutDir, "entry.js");
293
+ if (fs.existsSync(builtEntry)) {
294
+ const code = fs.readFileSync(builtEntry, "utf-8");
295
+ if (code.includes("doesn't expose the `require` function")) {
296
+ const specs = [...new Set([...code.matchAll(/ne\("([^"]+)"\)/g)].map(m => m[1]))];
297
+ throw new Error(
298
+ `[ojm] ${definition.name}@${definition.version} 产物含运行期 require(${specs.join(", ") || "?"})`
299
+ + "——浏览器没有 require,模块加载即抛错。"
300
+ + "根因:该 CJS 依赖被标为 bundle(自带副本),但它 require 的共享包被 external 了。"
301
+ + "修复:把它从 SHARED_DEPS 的 bundle 列表移回共享资产,或改用该依赖的 ESM 入口。",
302
+ );
303
+ }
304
+ }
305
+
306
+ const prefix = `${baseUrl}/modules/${definition.name}/${definition.version}/`;
307
+ const chunks: ChunkEntry[] = [];
308
+ const css: string[] = [];
309
+
310
+ for (const info of emitted.values()) {
311
+ if (info.type === "asset") {
312
+ if (info.fileName.endsWith(".css"))
313
+ css.push(`${prefix}${info.fileName}`);
314
+ continue;
315
+ }
316
+
317
+ // P7.3:entry 也进 chunks(lazy:false)——宿主 L2 完整性按 chunks[]
318
+ // 注入 modulepreload+integrity,此前 entry 被 isEntry 跳过导致
319
+ // 顶层 integrity 成为死字段(评审 S3:入口恰好不受保护)
320
+ chunks.push({
321
+ url: `${prefix}${info.fileName}`,
322
+ integrity: sha384(path.join(moduleOutDir, info.fileName)),
323
+ lazy: info.isEntry ? false : info.isDynamicEntry,
324
+ });
325
+ }
326
+
327
+ // §4.6:lazy chunk 不受 L2 完整性保护,构建期必须显式提示
328
+ const lazyChunks = chunks.filter(c => c.lazy);
329
+ if (lazyChunks.length > 0) {
330
+ console.warn(
331
+ `[ojm] 模块 "${definition.name}" 含 ${lazyChunks.length} 个 lazy chunk,`
332
+ + "它们在 L2 完整性档位下不受保护(§4.7 D7):\n"
333
+ + `${lazyChunks.map(c => ` · ${c.url}`).join("\n")}\n`
334
+ + "若这些 chunk 也要求完整性,请升级到 L3(Service Worker)或使用逃生通道(§4.7)。",
335
+ );
336
+ }
337
+
338
+ // P7.9 / B11:扫描产物中的裸说明符,构建期拦截 importmap 无法解析的
339
+ // 深路径(如 dayjs/plugin/utc——isSharedDep 前缀命中但 importmap 只有
340
+ // 精确键,浏览器必抛 Failed to resolve module specifier);
341
+ // 顺带以真实 import 分析落地 C8 表外依赖告警(不再只扫 package.json 字段)
342
+ assertResolvableSpecifiers(definition.name, moduleOutDir, emitted);
343
+
344
+ built.push({
345
+ name: definition.name,
346
+ version: definition.version,
347
+ enabled: true,
348
+ dependencies: definition.config?.dependencies ?? [],
349
+ peerRuntime: definition.peerRuntime,
350
+ entry: `${prefix}entry.js`,
351
+ integrity: sha384(path.join(moduleOutDir, "entry.js")),
352
+ css,
353
+ chunks,
354
+ routePrefix: resolveRoutePrefix(layout, definition.name),
355
+ });
356
+ }
357
+
358
+ const manifestPath = path.join(outDir, "modules.json");
359
+ fs.mkdirSync(outDir, { recursive: true });
360
+ fs.writeFileSync(manifestPath, `${JSON.stringify(built, null, 2)}\n`);
361
+ console.log(`[ojm] 清单已生成 → ${manifestPath}`);
362
+
363
+ return built;
364
+ }