@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.
- package/devkit/README.md +7 -4
- package/devkit/SKILL.md +4 -1
- package/devkit/manual.md +4 -2
- package/devkit/module-dev-and-cdn-playground.md +7 -0
- package/devkit/publish-walkthrough.md +199 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +5 -5
- package/shell-dist/index.html +3 -3
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/vendor-devkit.test.ts +87 -0
- package/src/build/backend.ts +131 -0
- package/src/build/module-build.ts +364 -0
- package/src/build/module-def.ts +381 -0
- package/src/build/site-merge.ts +33 -0
- package/src/build.ts +16 -885
- package/src/bundle.ts +61 -0
- package/src/cli.ts +4 -1
- package/src/init.ts +12 -35
- package/src/lifecycle/__tests__/bundle-summary.test.ts +106 -0
- package/src/lifecycle/__tests__/bundle.test.ts +6 -0
- package/src/vendor.ts +34 -0
- package/templates/openapi.json +7 -0
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +8 -3
- package/templates/src/app/api/_platform/_lifecycle/steps/register.ts +53 -0
- package/templates/src/app/api/_platform/_shared/service/domain/roles.ts +4 -1
- package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +7 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +3 -0
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +3 -0
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +8 -0
- package/templates/src/app/api/_platform/schema.yaml +2 -0
- package/templates/src/app/api/system/roles/api.ts +2 -1
- package/templates/src/app/web/system/__client/api.schemas.ts +1 -0
- package/templates/src/app/web/system/pages/roles/index.tsx +7 -1
- package/templates/tests/lifecycle/role-title-translate.spec.ts +171 -0
- package/templates/tests/rbac-roles.spec.ts +9 -0
- package/vendor/host-versions.json +3 -3
package/src/build.ts
CHANGED
|
@@ -1,885 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
import { moduleDir, resolveConfigPath, resolveLayout, scanModules, tryResolveLayout } from "./layout";
|
|
18
|
-
import { requireModuleRootCapability } from "./oj-caps";
|
|
19
|
-
import { isPrebuiltDep, isSharedDep, SHARED_DEPS } from "./shared-deps";
|
|
20
|
-
import { normalizeSiteBase, rewriteIndexHtml } from "./site-base";
|
|
21
|
-
import { resolveOjBin } from "./vendor";
|
|
22
|
-
import { checkSharedVersions, resolveShellDist } from "./versions";
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* `@oj-module/runtime` 的只读占位源码(设计文档 B10 / §4.3)。
|
|
26
|
-
*
|
|
27
|
-
* 读取模块定义时只需要 entry 顶层调用 `defineModule({ name, version, ... })` 的
|
|
28
|
-
* 结果,而绝不需要真正加载框架运行时(其产物含 Vite 专有的 `?react`/`?url` svg
|
|
29
|
-
* 导入,Node 下无法加载)。通过 esbuild 虚拟模块插件内联进 bundle,从源头避开 svg
|
|
30
|
-
* 问题;其余共享依赖保持 external,由 tsx/Node 在 import() 时按真实包加载。
|
|
31
|
-
*
|
|
32
|
-
* 这里列出运行时的「值导出」,使 esbuild 不会报 missing export(类型导出无需提供)。
|
|
33
|
-
* `defineModule` 仅把入参原样回传,CLI 即可读取 name/version 等元数据。
|
|
34
|
-
*/
|
|
35
|
-
const RUNTIME_STUB_SOURCE = `
|
|
36
|
-
const _fn = (...a) => a[a.length - 1];
|
|
37
|
-
const _obj = () => ({});
|
|
38
|
-
// 组件
|
|
39
|
-
export const BasicContent = _fn;
|
|
40
|
-
export const BasicButton = _fn;
|
|
41
|
-
export const BasicTable = _fn;
|
|
42
|
-
export const Iframe = _fn;
|
|
43
|
-
export const AccessControl = _fn;
|
|
44
|
-
export const FormAvatarItem = _fn;
|
|
45
|
-
export const FormTreeItem = _fn;
|
|
46
|
-
export const AntdApp = _fn;
|
|
47
|
-
// 页面级错误边界(增量⑥):宿主壳与模块自绘子树包裹用
|
|
48
|
-
export const ErrorBoundary = _fn;
|
|
49
|
-
export const PageError = _fn;
|
|
50
|
-
// api
|
|
51
|
-
export const notify = _obj;
|
|
52
|
-
export const fetchPie = _fn;
|
|
53
|
-
export const fetchLine = _fn;
|
|
54
|
-
export const fetchLogin = _fn;
|
|
55
|
-
export const fetchLogout = _fn;
|
|
56
|
-
export const fetchAsyncRoutes = _fn;
|
|
57
|
-
export const fetchUserInfo = _fn;
|
|
58
|
-
export const fetchRefreshToken = _fn;
|
|
59
|
-
export const fetchRoleList = _fn;
|
|
60
|
-
export const fetchAddRoleItem = _fn;
|
|
61
|
-
export const fetchUpdateRoleItem = _fn;
|
|
62
|
-
export const fetchDeleteRoleItem = _fn;
|
|
63
|
-
export const fetchRoleMenu = _fn;
|
|
64
|
-
export const fetchMenuByRoleId = _fn;
|
|
65
|
-
export const fetchMenuList = _fn;
|
|
66
|
-
export const fetchAddMenuItem = _fn;
|
|
67
|
-
export const fetchUpdateMenuItem = _fn;
|
|
68
|
-
export const fetchDeleteMenuItem = _fn;
|
|
69
|
-
// RBAC(阶段 C 增补,与 runtime api/rbac 对齐)
|
|
70
|
-
export const fetchRbacRoleList = _fn;
|
|
71
|
-
export const fetchRbacPermissionList = _fn;
|
|
72
|
-
export const fetchRbacCreateRole = _fn;
|
|
73
|
-
export const fetchRbacUpdateBindings = _fn;
|
|
74
|
-
export const fetchRbacDeleteRole = _fn;
|
|
75
|
-
export const fetchRbacUserList = _fn;
|
|
76
|
-
export const fetchRbacCreateUser = _fn;
|
|
77
|
-
export const fetchRbacUpdateUserRoles = _fn;
|
|
78
|
-
export const fetchRbacAdminResetPassword = _fn;
|
|
79
|
-
export const fetchRbacSetForbidden = _fn;
|
|
80
|
-
export const fetchRbacOplog = _fn;
|
|
81
|
-
export const fetchRbacOplogPost = _fn;
|
|
82
|
-
export const fetchRbacSettings = _fn;
|
|
83
|
-
export const fetchRbacUpdateSetting = _fn;
|
|
84
|
-
export const fetchRbacContacts = _fn;
|
|
85
|
-
export const fetchRbacContactDetail = _fn;
|
|
86
|
-
export const fetchLoginMethods = _fn;
|
|
87
|
-
export const fetchChangePassword = _fn;
|
|
88
|
-
export const fetchLdapLogin = _fn;
|
|
89
|
-
// 方案路由归并(P1-14/R21 读端点 + 分组纯函数)
|
|
90
|
-
export const fetchSolutionRoutes = _fn;
|
|
91
|
-
export const roleLabel = _fn;
|
|
92
|
-
export const canEditRoleBindings = _fn;
|
|
93
|
-
export const canDeleteRole = _fn;
|
|
94
|
-
export const canAssignRole = _fn;
|
|
95
|
-
export const bindingDiff = _fn;
|
|
96
|
-
export const groupByModule = _fn;
|
|
97
|
-
export const groupSettingsByModule = _fn;
|
|
98
|
-
// hooks 与权限常量
|
|
99
|
-
export const useAccess = _fn;
|
|
100
|
-
export const usePreferences = _fn;
|
|
101
|
-
// i18n 初始化(宿主链路消费)
|
|
102
|
-
export const setupI18n = _fn;
|
|
103
|
-
// 全局副作用(宿主链路消费)
|
|
104
|
-
export const LayoutEffects = _fn;
|
|
105
|
-
export const accessControlCodes = _obj;
|
|
106
|
-
export const AccessControlRoles = _obj;
|
|
107
|
-
export const permissionPrefix = "permission:button";
|
|
108
|
-
// store
|
|
109
|
-
export const useAccessStore = _fn;
|
|
110
|
-
export const useAuthStore = _fn;
|
|
111
|
-
// 方案路由归并 store(token 就绪自动重拉,R21)
|
|
112
|
-
export const useSolutionRoutes = _fn;
|
|
113
|
-
export const useUserStore = _fn;
|
|
114
|
-
export const useTabsStore = _fn;
|
|
115
|
-
// 租户上下文(L2):请求头由运行时注入,模块只在登出清理 / URL 带 tenant 参数时读写
|
|
116
|
-
export const useTenantStore = _fn;
|
|
117
|
-
export const currentTenant = () => "default";
|
|
118
|
-
export const bootstrapTenant = () => "default";
|
|
119
|
-
export const DEFAULT_TENANT = "default";
|
|
120
|
-
export const TENANT_HEADER = "X-TENANT-ID";
|
|
121
|
-
export const TENANT_QUERY_PARAM = "tenant";
|
|
122
|
-
// 页签 key 唯一正确取口(keepAlive 下 useLocation 是激活页位置,0.1.21)
|
|
123
|
-
export const useTabKey = _fn;
|
|
124
|
-
// 图标
|
|
125
|
-
export const menuIcons = _obj;
|
|
126
|
-
export const EmbeddedIcon = _fn;
|
|
127
|
-
export const ExternalIcon = _fn;
|
|
128
|
-
export const LayoutCenterIcon = _fn;
|
|
129
|
-
export const LayoutLeftIcon = _fn;
|
|
130
|
-
export const LayoutRightIcon = _fn;
|
|
131
|
-
export const MixedNavigationIcon = _fn;
|
|
132
|
-
export const OutsidePageIcon = _fn;
|
|
133
|
-
export const ProfileCardIcon = _fn;
|
|
134
|
-
export const ServerErrorIcon = _fn;
|
|
135
|
-
export const SideNavigationIcon = _fn;
|
|
136
|
-
export const TopNavigationIcon = _fn;
|
|
137
|
-
export const TwoColumnNavigationIcon = _fn;
|
|
138
|
-
export const RiAccountCircleLine = _fn;
|
|
139
|
-
export const RiContrastFill = _fn;
|
|
140
|
-
export const RiFullscreenExitLine = _fn;
|
|
141
|
-
export const RiFullscreenLine = _fn;
|
|
142
|
-
export const RiMailCheckLine = _fn;
|
|
143
|
-
export const RiMoonIcon = _fn;
|
|
144
|
-
export const RiReactjsLine = _fn;
|
|
145
|
-
export const RiSunIcon = _fn;
|
|
146
|
-
export const RiUserSettingsLine = _fn;
|
|
147
|
-
// 工具与常量
|
|
148
|
-
export const handleTree = _fn;
|
|
149
|
-
export const traverseTreeValues = _fn;
|
|
150
|
-
export const filterTree = _fn;
|
|
151
|
-
export const mapTree = _fn;
|
|
152
|
-
export const getAllExpandedKeys = _fn;
|
|
153
|
-
export const getAppInfo = _fn;
|
|
154
|
-
export const getRedirectPath = _fn;
|
|
155
|
-
// 站点部署 base(子路径部署):stub 只需占位,元数据读取期不拼 URL
|
|
156
|
-
export const resolveSiteBase = _fn;
|
|
157
|
-
export const withSiteBase = _fn;
|
|
158
|
-
// API 端点 base(整页跳转的后端端点、上传 action 用它拼 URL,与站点 base 正交)
|
|
159
|
-
export const resolveApiBase = _fn;
|
|
160
|
-
export const getYesNoOptions = _fn;
|
|
161
|
-
export const getBooleanOptions = _fn;
|
|
162
|
-
// 模块契约
|
|
163
|
-
export const defineModule = (d) => d;
|
|
164
|
-
export const defineRoutes = (...a) => a[a.length - 1];
|
|
165
|
-
export const defineGuard = (...a) => a[a.length - 1];
|
|
166
|
-
export const getModules = () => [];
|
|
167
|
-
export const getModule = () => undefined;
|
|
168
|
-
export const getRoutes = () => [];
|
|
169
|
-
export const getHostRoutes = () => [];
|
|
170
|
-
// 模块生效 web 路由前缀段取值口(R1.3:写进 modules.json 的 routePrefix,未声明回落 name)
|
|
171
|
-
export const getModuleRoutePrefix = _fn;
|
|
172
|
-
export const getRegisteredStore = () => undefined;
|
|
173
|
-
export const getRegisteredApiPrefix = () => undefined;
|
|
174
|
-
export const loadAll = async () => [];
|
|
175
|
-
export const unloadModule = async () => {};
|
|
176
|
-
export const useSlotNodes = () => [];
|
|
177
|
-
// AC-D15:zod re-export——stub 用可调用代理(契约 schema 不在元数据读取期求值,仅占位防 missing export)
|
|
178
|
-
export const z = new Proxy({}, { get: () => _fn });
|
|
179
|
-
// R29:ky 实例 re-export(第一方市场编排面)——同 z,元数据读取期仅需可调用占位
|
|
180
|
-
export const request = new Proxy({}, { get: () => _fn });
|
|
181
|
-
`;
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* esbuild 插件:把 `@oj-module/runtime` 解析到一个内联的只读占位虚拟模块,
|
|
185
|
-
* 避免真正加载框架运行时(含 svg)。用虚拟模块而非 alias 指向实体文件,是因为
|
|
186
|
-
* 实体路径依赖 import.meta.url,在 vitest 等变换环境下拿不到合法的 file URL。
|
|
187
|
-
*/
|
|
188
|
-
const runtimeStubPlugin: EsbuildPlugin = {
|
|
189
|
-
name: "ojm-runtime-stub",
|
|
190
|
-
setup(b) {
|
|
191
|
-
b.onResolve({ filter: /^@oj-module\/runtime$/ }, () => ({
|
|
192
|
-
path: "@oj-module/runtime",
|
|
193
|
-
namespace: "ojm-runtime-stub",
|
|
194
|
-
}));
|
|
195
|
-
b.onLoad({ filter: /.*/, namespace: "ojm-runtime-stub" }, () => ({
|
|
196
|
-
contents: RUNTIME_STUB_SOURCE,
|
|
197
|
-
loader: "js",
|
|
198
|
-
resolveDir: process.cwd(),
|
|
199
|
-
}));
|
|
200
|
-
},
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* 把 entry 内所有动态 import 的目标(lazy 页面、i18n JSON)替换为虚拟空模块:
|
|
205
|
-
* 元数据读取只需要 defineModule 的入参对象,动态模块永远不该执行。
|
|
206
|
-
* 若放任这些模块进 bundle,esbuild 输出 ESM 时会把页面模块的裸导入 hoist 到
|
|
207
|
-
* bundle 顶层(如 pro-components),Node import() 时即被求值并可能加载失败;
|
|
208
|
-
* 若标为 external,vitest 等变换环境又会在 transform 阶段强行解析相对说明符。
|
|
209
|
-
* 空模块两条路都堵死(P3.4)。
|
|
210
|
-
*/
|
|
211
|
-
const dynamicImportStubPlugin: EsbuildPlugin = {
|
|
212
|
-
name: "ojm-dynamic-import-stub",
|
|
213
|
-
setup(b) {
|
|
214
|
-
// esbuild 的 onResolve 不支持按 kind 过滤,在回调里判断后放行其余 resolver
|
|
215
|
-
b.onResolve({ filter: /^\./ }, (args) => {
|
|
216
|
-
if (args.kind === "dynamic-import") {
|
|
217
|
-
return { path: args.path, namespace: "ojm-dynamic-stub" };
|
|
218
|
-
}
|
|
219
|
-
return null;
|
|
220
|
-
});
|
|
221
|
-
b.onLoad({ filter: /.*/, namespace: "ojm-dynamic-stub" }, () => ({
|
|
222
|
-
contents: "export default {};",
|
|
223
|
-
loader: "js",
|
|
224
|
-
}));
|
|
225
|
-
},
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* 元数据读取封闭性(P3 验收发现):裸导入全部桩化,`ojm init` 产出的外部工程
|
|
230
|
-
* 尚未安装依赖(@oj-module/* 未发布),真实 import() 会
|
|
231
|
-
* ERR_MODULE_NOT_FOUND;读元数据只需要 defineModule 入参,共享依赖执行与否无关。
|
|
232
|
-
*
|
|
233
|
-
* - 值统一为「自引用可调用代理」:任意属性链访问/调用都安全(JSX 元素在
|
|
234
|
-
* 模块顶层求值等形态)。
|
|
235
|
-
* - 从 modulesSrc 源码扫出的具名导入经 ownKeys/getOwnPropertyDescriptor 陷阱
|
|
236
|
-
* 静态暴露——esbuild 对 CJS 桩做 __toESM 属性拷贝(走这两把陷阱),仅 get
|
|
237
|
-
* 陷阱拷不出名字,顶层 `React.createElement` 会直接崩(验收实测)。
|
|
238
|
-
* - `#` 开头的工程子路径导入不桩化,走默认解析。
|
|
239
|
-
*/
|
|
240
|
-
/**
|
|
241
|
-
* 扫描 modulesSrc 下全部源码,收集「裸说明符 → 具名导入列表」。
|
|
242
|
-
* 只为给桩模块提供可静态暴露的名字(供 esbuild __toESM 拷贝),不求精确:
|
|
243
|
-
* 解析不到的形状(多行、注释包裹等)只会让该名字未被种子,运行时落到 get
|
|
244
|
-
* 陷阱兜底;扫到未使用的名字也无害。
|
|
245
|
-
*/
|
|
246
|
-
function collectBareImportedNames(...roots: string[]): Map<string, string[]> {
|
|
247
|
-
const found = new Map<string, Set<string>>();
|
|
248
|
-
const record = (spec: string, clause: string) => {
|
|
249
|
-
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("#"))
|
|
250
|
-
return;
|
|
251
|
-
const set = found.get(spec) ?? new Set<string>();
|
|
252
|
-
for (const raw of clause.split(",")) {
|
|
253
|
-
const name = raw.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
|
|
254
|
-
if (name && /^[\w$]+$/.test(name))
|
|
255
|
-
set.add(name);
|
|
256
|
-
}
|
|
257
|
-
set.add("default");
|
|
258
|
-
found.set(spec, set);
|
|
259
|
-
};
|
|
260
|
-
const walk = (dir: string) => {
|
|
261
|
-
let entries: fs.Dirent[];
|
|
262
|
-
try {
|
|
263
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
for (const entry of entries) {
|
|
269
|
-
const filePath = path.join(dir, entry.name);
|
|
270
|
-
if (entry.isDirectory()) {
|
|
271
|
-
walk(filePath);
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
if (!/\.(?:ts|tsx|js|jsx|mjs)$/.test(entry.name))
|
|
275
|
-
continue;
|
|
276
|
-
const source = fs.readFileSync(filePath, "utf-8");
|
|
277
|
-
for (const m of source.matchAll(/(?:import|export)\s+(?:type\s+)?\{([^}]*)\}\s*(?:from\s*)?["']([^"']+)["']/g))
|
|
278
|
-
record(m[2], m[1]);
|
|
279
|
-
for (const m of source.matchAll(/import\s+(\w+)\s*(?:,\s*\{([^}]*)\}\s*)?from\s*["']([^"']+)["']/g)) {
|
|
280
|
-
if (m[1] && m[3])
|
|
281
|
-
record(m[3], m[1]);
|
|
282
|
-
if (m[2] && m[3])
|
|
283
|
-
record(m[3], m[2]);
|
|
284
|
-
}
|
|
285
|
-
// namespace / default 导入的属性访问(`React.lazy` / `ns.foo()`):
|
|
286
|
-
// 名字从成员访问推断,否则 __toESM 拷贝后这些名字不存在(playground 实测)
|
|
287
|
-
const bindings = new Map<string, string>();
|
|
288
|
-
for (const m of source.matchAll(/import\s+(?:\*\s+as\s+)?(\w+)\s+from\s*["']([^"']+)["']/g)) {
|
|
289
|
-
if (m[1] && m[2] && !m[2].startsWith(".") && !m[2].startsWith("#"))
|
|
290
|
-
bindings.set(m[1], m[2]);
|
|
291
|
-
}
|
|
292
|
-
for (const [binding, spec] of bindings) {
|
|
293
|
-
for (const m of source.matchAll(new RegExp(`\\b${binding}\\.(\\w+)`, "g"))) {
|
|
294
|
-
const set = found.get(spec) ?? new Set<string>();
|
|
295
|
-
set.add(m[1]);
|
|
296
|
-
found.set(spec, set);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
};
|
|
301
|
-
for (const root of roots)
|
|
302
|
-
walk(root);
|
|
303
|
-
return new Map([...found].map(([spec, set]) => [spec, [...set]]));
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** 每次元数据读取一个实例:modulesSrc 扫描出的「裸说明符 → 具名导入列表」 */
|
|
307
|
-
function makeBareImportStubPlugin(namesBySpec: Map<string, string[]>): EsbuildPlugin {
|
|
308
|
-
return {
|
|
309
|
-
name: "ojm-bare-import-stub",
|
|
310
|
-
setup(b) {
|
|
311
|
-
b.onResolve({ filter: /^[^./#]/ }, args => ({ path: args.path, namespace: "ojm-bare-stub" }));
|
|
312
|
-
b.onLoad({ filter: /.*/, namespace: "ojm-bare-stub" }, (args) => {
|
|
313
|
-
const names = namesBySpec.get(args.path) ?? [];
|
|
314
|
-
return {
|
|
315
|
-
contents: [
|
|
316
|
-
// `apply` 返回 value(自身)而非 undefined:桩必须**可无限链式调用**。
|
|
317
|
-
// 反例(真踩过):zustand v5 是柯里化 API——`create()(initializer)`
|
|
318
|
-
// 先调一次取工厂、再调一次取 store,且**都在模块顶层求值**。
|
|
319
|
-
// 若 apply 返回 undefined,元数据读取即抛
|
|
320
|
-
// `(0, import_zustand.create)(...) is not a function`,
|
|
321
|
-
// 整个 `ojm dev/build` 起不来(模块代码本身完全合法)。
|
|
322
|
-
// 同类形态:`axios.create(...)`、`i18next.createInstance(...)`、
|
|
323
|
-
// `createTheme(...)` 等顶层库工厂。桩只在元数据读取期跑,放宽只会
|
|
324
|
-
// 把「崩溃」变成「无害空转」。
|
|
325
|
-
"const value = new Proxy(function () {}, { get: () => value, apply: () => value });",
|
|
326
|
-
`const names = ${JSON.stringify(names)};`,
|
|
327
|
-
"module.exports = new Proxy({}, {",
|
|
328
|
-
"\tget: () => value,",
|
|
329
|
-
"\tgetOwnPropertyDescriptor: (_t, p) => names.includes(p) ? { enumerable: true, configurable: true, get: () => value } : undefined,",
|
|
330
|
-
"\townKeys: () => names,",
|
|
331
|
-
"});",
|
|
332
|
-
].join("\n"),
|
|
333
|
-
loader: "js",
|
|
334
|
-
};
|
|
335
|
-
});
|
|
336
|
-
},
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/** modules.json 中的 chunk 条目 */
|
|
341
|
-
export interface ChunkEntry {
|
|
342
|
-
url: string
|
|
343
|
-
integrity: string
|
|
344
|
-
/** 懒加载 chunk:L2 完整性档位下不受保护(设计文档 §4.7) */
|
|
345
|
-
lazy: boolean
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** modules.json 中的模块条目 */
|
|
349
|
-
export interface BuiltModule {
|
|
350
|
-
name: string
|
|
351
|
-
version: string
|
|
352
|
-
enabled: boolean
|
|
353
|
-
dependencies: string[]
|
|
354
|
-
peerRuntime?: string
|
|
355
|
-
entry: string
|
|
356
|
-
integrity: string
|
|
357
|
-
css: string[]
|
|
358
|
-
chunks: ChunkEntry[]
|
|
359
|
-
/**
|
|
360
|
-
* 生效的 web 路由前缀段(契约 C2-14:`web.route_prefix` 缺省 `/<name>`)。
|
|
361
|
-
* 运行期据其改写模块路由根(单根约定下仅根段),缺省 = name 保证零漂移。
|
|
362
|
-
*/
|
|
363
|
-
routePrefix: string
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* 解析模块生效的 web 路由前缀段(R1.1)。
|
|
368
|
-
* module-root:读 `src/<name>/solution.yaml` 的 `web.route_prefix`(契约 C2-14,缺省 `/<name>`);
|
|
369
|
-
* 未配置/非法/非 module-root → 缺省 `name`,与现有路由根(顶层段即 name)逐字节一致。
|
|
370
|
-
*/
|
|
371
|
-
function resolveRoutePrefix(layout: ProjectLayout, name: string): string {
|
|
372
|
-
if (layout.kind !== "module-root")
|
|
373
|
-
return name;
|
|
374
|
-
const solPath = path.join(moduleDir(layout, name), "solution.yaml");
|
|
375
|
-
if (!fs.existsSync(solPath))
|
|
376
|
-
return name;
|
|
377
|
-
try {
|
|
378
|
-
const doc = parseYaml(fs.readFileSync(solPath, "utf-8")) as Record<string, unknown>;
|
|
379
|
-
const web = doc.web;
|
|
380
|
-
if (web && typeof web === "object" && !Array.isArray(web)) {
|
|
381
|
-
const rp = (web as Record<string, unknown>).route_prefix;
|
|
382
|
-
if (typeof rp === "string" && /^\/[a-z][a-z0-9-]{0,63}$/.test(rp))
|
|
383
|
-
return rp.slice(1);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
catch {
|
|
387
|
-
// YAML 解析失败 → 缺省,不阻断构建(契约校验会在别处暴露)
|
|
388
|
-
}
|
|
389
|
-
return name;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const ALGORITHM = "sha384";
|
|
393
|
-
|
|
394
|
-
function sha384(file: string): string {
|
|
395
|
-
const digest = crypto.createHash(ALGORITHM).update(fs.readFileSync(file)).digest("base64");
|
|
396
|
-
return `${ALGORITHM}-${digest}`;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* 用 esbuild 把 entry 打包成单文件(含 runtime 占位 stub 与动态 import 空 stub),
|
|
401
|
-
* 写出到工程内临时目录后以普通 `import()` 加载,从而读取模块定义
|
|
402
|
-
* (name/version/peerRuntime/config)而无需真正加载整个框架运行时
|
|
403
|
-
* (其源码含 Vite 专有的 `?react`/`?url` svg 导入),替代脆弱的正则(B10)。
|
|
404
|
-
*
|
|
405
|
-
* P3.4 起同时供主仓库 `scripts/build-modules.ts` 复用(经 exports `./build`)。
|
|
406
|
-
*/
|
|
407
|
-
export async function readModuleDefinition(entryFile: string, projectRoot: string) {
|
|
408
|
-
// 必须落在工程目录内(而非 os.tmpdir),否则 bundle 外部化的共享依赖
|
|
409
|
-
// (react / antd …)在 import() 时无法从 /tmp 解析到 node_modules。
|
|
410
|
-
const outDir = fs.mkdtempSync(path.join(projectRoot, ".ojm-tmp-"));
|
|
411
|
-
try {
|
|
412
|
-
// entry 可能登记在 modulesSrc 之外(playground 登记仓库级模块),
|
|
413
|
-
// entry 所在目录同样入扫,保证其相对导入图的具名导入都被种子
|
|
414
|
-
const bareNames = collectBareImportedNames(resolveLayout(projectRoot).modulesSrc, path.dirname(entryFile));
|
|
415
|
-
await esbuild({
|
|
416
|
-
entryPoints: [entryFile],
|
|
417
|
-
// 固定输出名 entry.js:esbuild 默认取 entry 文件 basename,
|
|
418
|
-
// 会让非 entry.ts 文件名(如测试夹具)的产物对不上后续 import 路径
|
|
419
|
-
entryNames: "entry",
|
|
420
|
-
bundle: true,
|
|
421
|
-
format: "esm",
|
|
422
|
-
platform: "node",
|
|
423
|
-
packages: "external",
|
|
424
|
-
plugins: [runtimeStubPlugin, dynamicImportStubPlugin, makeBareImportStubPlugin(bareNames)],
|
|
425
|
-
outdir: outDir,
|
|
426
|
-
jsx: "automatic",
|
|
427
|
-
// 元数据读取不需要 css:empty 装载器把 `import "./styles.css"` 抹成空模块,
|
|
428
|
-
// 避免裸导入桩拦截 css 内部的 `@import "tailwindcss"`(js 桩无法注入 css)
|
|
429
|
-
loader: { ".ts": "ts", ".tsx": "tsx", ".json": "json", ".css": "empty" },
|
|
430
|
-
logLevel: "silent",
|
|
431
|
-
});
|
|
432
|
-
const bundled = path.join(outDir, "entry.js");
|
|
433
|
-
const mod = await import(pathToFileURL(bundled).href);
|
|
434
|
-
const definition = mod.default;
|
|
435
|
-
if (!definition?.name || !definition?.version) {
|
|
436
|
-
throw new Error(`${entryFile} 的 default 导出缺少 name 或 version`);
|
|
437
|
-
}
|
|
438
|
-
return definition as {
|
|
439
|
-
name: string
|
|
440
|
-
version: string
|
|
441
|
-
peerRuntime?: string
|
|
442
|
-
config?: { dependencies?: string[] }
|
|
443
|
-
// R5:entry.ts 声明的路由树(含 handle.category/order/title),
|
|
444
|
-
// buildBundle 据此派生 manifest.routes(单一真源,避免手工双写漂移)
|
|
445
|
-
routes?: RouteDefinitionLike[]
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
finally {
|
|
449
|
-
fs.rmSync(outDir, { recursive: true, force: true });
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function warnUnsharedDeps(projectRoot: string) {
|
|
454
|
-
const pkgPath = path.join(projectRoot, "package.json");
|
|
455
|
-
if (!fs.existsSync(pkgPath))
|
|
456
|
-
return;
|
|
457
|
-
|
|
458
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
459
|
-
const deps = Object.keys(pkg.dependencies ?? {});
|
|
460
|
-
const unshared = deps.filter(dep => !isSharedDep(dep) && !dep.startsWith("@oj-module/"));
|
|
461
|
-
|
|
462
|
-
if (unshared.length) {
|
|
463
|
-
console.warn(
|
|
464
|
-
`[ojm] ⚠️ 以下运行时依赖不在共享表内,会被打进模块产物:${unshared.join(", ")}\n`
|
|
465
|
-
+ " 若它应与宿主共用,请加入 SHARED_DEPS 并在宿主 importmap 中映射(设计文档 C8)",
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/** 提取产物文件中的全部裸说明符(排除相对/绝对/# 路径) */
|
|
471
|
-
function collectBareSpecifiers(file: string): string[] {
|
|
472
|
-
const source = fs.readFileSync(file, "utf-8");
|
|
473
|
-
const found = new Set<string>();
|
|
474
|
-
const patterns = [
|
|
475
|
-
/(?:import|export)\s[^'";]*?from\s*["']([^"']+)["']/g, // import/export ... from "x"
|
|
476
|
-
/import\s*["']([^"']+)["']/g, // 副作用导入 import "x"
|
|
477
|
-
/import\(\s*["']([^"']+)["']\s*\)/g, // 动态 import("x")
|
|
478
|
-
];
|
|
479
|
-
for (const pattern of patterns) {
|
|
480
|
-
for (const match of source.matchAll(pattern)) {
|
|
481
|
-
const spec = match[1]!;
|
|
482
|
-
if (!spec.startsWith(".") && !spec.startsWith("/") && !spec.startsWith("#"))
|
|
483
|
-
found.add(spec);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
return [...found];
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* P7.9 / B11 + C8:构建期扫描模块产物的裸说明符。
|
|
491
|
-
* - 深路径命中共享表前缀但非精确 importmap 键(如 dayjs/plugin/utc):
|
|
492
|
-
* 构建期直接报错——importmap 无前缀通配,浏览器必抛
|
|
493
|
-
* "Failed to resolve module specifier",不能拖到运行期(§4.7 错误契约)。
|
|
494
|
-
* - 表外说明符:C8 构建期告警(按真实 import 分析,不依赖 package.json 字段)。
|
|
495
|
-
*/
|
|
496
|
-
function assertResolvableSpecifiers(
|
|
497
|
-
moduleName: string,
|
|
498
|
-
moduleOutDir: string,
|
|
499
|
-
emitted: Map<string, { type: string, fileName: string }>,
|
|
500
|
-
): void {
|
|
501
|
-
const exactKeys = new Set(SHARED_DEPS.map(dep => dep.specifier));
|
|
502
|
-
const deepPathErrors: string[] = [];
|
|
503
|
-
const unsharedWarnings = new Set<string>();
|
|
504
|
-
|
|
505
|
-
for (const info of emitted.values()) {
|
|
506
|
-
if (info.type !== "chunk")
|
|
507
|
-
continue;
|
|
508
|
-
for (const spec of collectBareSpecifiers(path.join(moduleOutDir, info.fileName))) {
|
|
509
|
-
if (exactKeys.has(spec))
|
|
510
|
-
continue;
|
|
511
|
-
if (isSharedDep(spec)) {
|
|
512
|
-
deepPathErrors.push(` · ${info.fileName} → "${spec}"`);
|
|
513
|
-
}
|
|
514
|
-
else {
|
|
515
|
-
unsharedWarnings.add(spec);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
if (deepPathErrors.length > 0) {
|
|
521
|
-
throw new Error(
|
|
522
|
-
`[ojm] 模块 "${moduleName}" 的产物含 importmap 无法解析的深路径说明符:\n`
|
|
523
|
-
+ `${deepPathErrors.join("\n")}\n`
|
|
524
|
-
+ "共享表只提供精确键(importmap 无前缀通配)。修复建议:改从包根导入"
|
|
525
|
-
+ "(如 dayjs 插件改在宿主侧注册),或联系框架方在 SHARED_DEPS 增补该深路径条目。",
|
|
526
|
-
);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
if (unsharedWarnings.size > 0) {
|
|
530
|
-
console.warn(
|
|
531
|
-
`[ojm] ⚠️ 模块 "${moduleName}" import 了共享表外的三方库:${[...unsharedWarnings].join(", ")}\n`
|
|
532
|
-
+ " 它们会被打进模块产物。若应与宿主共用,请加入 SHARED_DEPS(设计文档 C8)",
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* 构建后端(api/src 存在时编排 `bin/oj build`,D8)。
|
|
539
|
-
*
|
|
540
|
-
* 绝不在此处跑 migrate:oj build 本身零磁盘副作用,`ojm build` 保持零 DB
|
|
541
|
-
* 副作用(CI/无 DB 环境可跑);迁移由 preview 在起服务前应用(设计 §6)。
|
|
542
|
-
* 无后端源码返回 false(纯前端工程跳过)。
|
|
543
|
-
*/
|
|
544
|
-
export async function buildBackend(projectRoot: string): Promise<boolean> {
|
|
545
|
-
// 没有模块源码的工程(如 market 的隔离站点根)直接跳过——不能因为探测不到布局就抛
|
|
546
|
-
const layout = tryResolveLayout(projectRoot);
|
|
547
|
-
const moduleRoot = layout?.kind === "module-root";
|
|
548
|
-
const apiSrc = moduleRoot
|
|
549
|
-
? layout.modulesSrc
|
|
550
|
-
: path.join(projectRoot, "api/src");
|
|
551
|
-
if (!fs.existsSync(apiSrc))
|
|
552
|
-
return false;
|
|
553
|
-
|
|
554
|
-
const oj = resolveOjBin(projectRoot);
|
|
555
|
-
if (!fs.existsSync(oj)) {
|
|
556
|
-
throw new Error(
|
|
557
|
-
`[ojm] 找到 ${path.relative(projectRoot, apiSrc) || "."} 但缺少 ${oj}。\n`
|
|
558
|
-
+ "请重跑 ojm init 幂等补缺(不会覆盖 config 与用户代码)。",
|
|
559
|
-
);
|
|
560
|
-
}
|
|
561
|
-
// 能力闸门先于任何写入:失败时磁盘上不留半成品
|
|
562
|
-
if (moduleRoot)
|
|
563
|
-
requireModuleRootCapability(projectRoot);
|
|
564
|
-
|
|
565
|
-
// 别名投影:模块增删后要让 TS 的 paths 跟上(否则 `#/<模块名>/…` 在编辑器/typecheck
|
|
566
|
-
// 里解析不到,退化成 any)。内容未变不写盘。
|
|
567
|
-
const syncedPaths = syncAliasPaths(projectRoot);
|
|
568
|
-
if (syncedPaths === "created" || syncedPaths === "updated")
|
|
569
|
-
console.log(`[ojm] 已${syncedPaths === "created" ? "生成" : "更新"} ${ALIAS_PATHS_FILE}(\`#/<模块名>/…\` 的 TS paths 投影)`);
|
|
570
|
-
|
|
571
|
-
const apiDist = layout?.apiDist ?? path.join(projectRoot, "dist", "api");
|
|
572
|
-
console.log("[ojm] oj build(后端)…");
|
|
573
|
-
// 必须显式给 -c:oj build 的 -c 缺省是 ./config.yaml(相对 CWD),而 config 在
|
|
574
|
-
// <project>/config/ 下——漏传会静默读不到配置,连带 tasks 镜像目录回落默认 "tasks"、
|
|
575
|
-
// sql_guard 声明校验不生效(oj 只 warn 两行,极易被忽略)。
|
|
576
|
-
const configPath = resolveConfigPath(projectRoot);
|
|
577
|
-
const configArgs = fs.existsSync(configPath) ? ["-c", configPath] : [];
|
|
578
|
-
// --no-minify:oj 的 swc minify 当前会把产物压坏,必须绕开。实测两种坏法:
|
|
579
|
-
// 1) `const rows = await db.query(...); rows[0]` → 压成 `await db.query(...)[0]`,
|
|
580
|
-
// 下标先于 await 求值,Promise 上取 [0] 得 undefined(登录恒 401);
|
|
581
|
-
// 2) `if (cond) x = y` → 压成 `cond && x = y`,赋值作 && 右项缺括号 →
|
|
582
|
-
// SyntaxError: Invalid left-hand side in assignment(端点 500)。
|
|
583
|
-
// 0.1.32 / 0.1.50 均复现。产物体积换正确性,等上游修好再撤这个旗标。
|
|
584
|
-
// v0.1.85:`--module-root <dir>` 与 `-d` 互斥(都是「源码树根」)。新布局给 src,
|
|
585
|
-
// oj 在子树内递归找 manifest.yaml(命中 src/<name>/api/manifest.yaml 即停)。
|
|
586
|
-
const sourceArgs = layout?.moduleRoot ? ["--module-root", layout.moduleRoot] : ["-d", apiSrc];
|
|
587
|
-
// 出包前先自己确认 config 能读:读不动时 oj build 会静默关掉 sql_guard(见函数注释)
|
|
588
|
-
if (configArgs.length)
|
|
589
|
-
assertSealedValuesOpenable(oj, configPath);
|
|
590
|
-
execFileSync(oj, [
|
|
591
|
-
"build",
|
|
592
|
-
...configArgs,
|
|
593
|
-
...sourceArgs,
|
|
594
|
-
"-o",
|
|
595
|
-
apiDist,
|
|
596
|
-
"--no-minify",
|
|
597
|
-
], { stdio: "inherit" });
|
|
598
|
-
// 宿主 versions.json 双落点:站点壳从 shell-dist 拷进 dist/web(mergeShellSite),
|
|
599
|
-
// 而方案安装器读的是 `dist/api/versions.json`(B-11:与 manifests.yaml 同层,用于
|
|
600
|
-
// peerRuntime 强制比对)。两处必须同源,否则新工程 `ojm build` 完仍装不上方案——
|
|
601
|
-
// preflight 直接 C2-09 fail-closed「无法读取宿主 runtime 版本」。
|
|
602
|
-
const versionsSrc = path.join(resolveShellDist(), "versions.json");
|
|
603
|
-
if (fs.existsSync(versionsSrc)) {
|
|
604
|
-
fs.mkdirSync(apiDist, { recursive: true });
|
|
605
|
-
fs.copyFileSync(versionsSrc, path.join(apiDist, "versions.json"));
|
|
606
|
-
}
|
|
607
|
-
return true;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* config 里的密封值(`oj secret seal` 产物)在 build 前试解一把——fail-closed。
|
|
612
|
-
*
|
|
613
|
-
* **为什么不能只信 oj 的 warn**:实测(oj 0.1.115)config 读不动时 `oj build` **退出码仍是 0**,
|
|
614
|
-
* 只往 stderr 甩两行「读配置失败 → 镜像目录回落默认 tasks / 本次 build 按 sql_guard=off 处理」,
|
|
615
|
-
* 然后照常出包。那两行夹在模块清单里极易被忽略,而关掉的 `sql_guard` 正是 tenant 声明校验
|
|
616
|
-
* (跨模块 SQL 的唯一守卫)——包看起来完好,检查其实没跑。
|
|
617
|
-
*
|
|
618
|
-
* 探法就是 oj 自己的排障命令 `oj secret open`:它和 `oj serve` / `oj build` 用同一套密钥解析
|
|
619
|
-
* (`OJ_SECRET_KEY` → `OJ_SECRET_KEY_FILE` → config 的 `secrets.private_key_path`),
|
|
620
|
-
* 解不开就非零退出。明文只可能出现在它的 stdout,这里直接丢弃,不落终端也不落日志。
|
|
621
|
-
*/
|
|
622
|
-
function assertSealedValuesOpenable(oj: string, configPath: string): void {
|
|
623
|
-
// 注释行不算:config 里留着注释掉的旧密文很常见,naive 匹配会让 build 因为一行 `#` 而失败
|
|
624
|
-
const live = fs.readFileSync(configPath, "utf-8")
|
|
625
|
-
.split("\n")
|
|
626
|
-
.filter(line => !line.trimStart().startsWith("#"))
|
|
627
|
-
.join("\n");
|
|
628
|
-
const sealed = [...new Set(live.match(/ENC\[[^\]\n]+\]/g) ?? [])];
|
|
629
|
-
for (const value of sealed) {
|
|
630
|
-
try {
|
|
631
|
-
execFileSync(oj, ["secret", "open", "-c", configPath, value], { stdio: ["ignore", "ignore", "pipe"] });
|
|
632
|
-
}
|
|
633
|
-
catch (e) {
|
|
634
|
-
const stderr = String((e as { stderr?: unknown }).stderr ?? "").trim();
|
|
635
|
-
const detail = stderr || (e as Error).message;
|
|
636
|
-
throw new Error(
|
|
637
|
-
`[ojm] config 里的密封值解不开,已停在出包前:${configPath}\n`
|
|
638
|
-
+ ` oj 说:${detail}\n`
|
|
639
|
-
+ "\n"
|
|
640
|
-
+ "不能放行:oj build 遇到读不动的 config 不会失败(退出码 0),只 warn 两行就继续——\n"
|
|
641
|
-
+ "tasks 镜像目录回落默认值,sql_guard(tenant 声明校验)直接关掉。那种包看着正常,\n"
|
|
642
|
-
+ "跨模块 SQL 的守卫却没跑过。\n"
|
|
643
|
-
+ "\n"
|
|
644
|
-
+ "两类原因,都修得了:\n"
|
|
645
|
-
+ " 1) 本机加载的私钥与密封时用的公钥不是一对——最常见是换机器/换部署目录时只带了\n"
|
|
646
|
-
+ " config.yaml,没带 config/secrets-private.pem;\n"
|
|
647
|
-
+ " 2) 密文被改过——手工编辑、复制被截断、编辑器把长行折了。\n"
|
|
648
|
-
+ "\n"
|
|
649
|
-
+ "单独验一条:oj secret open -c <config> '<那个 ENC[...] 值>'(成功=这条没问题,失败=就是它)\n"
|
|
650
|
-
+ "修法:把配对私钥带到本机(OJ_SECRET_KEY / OJ_SECRET_KEY_FILE / config 的 secrets.private_key_path\n"
|
|
651
|
-
+ " 三选一);密钥确实换了,就在本机重新 oj secret seal 覆写该值(明文走 stdin,别上命令行)。",
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
/**
|
|
658
|
-
* 全站合并(仅 `ojm build` 调用,设计 §5):清场后拷 shell dist 全量,
|
|
659
|
-
* 再由模块构建写入 modules.json 与 modules/。清场防旧哈希资产无限累积
|
|
660
|
-
* (shell 每次重构建生成新哈希文件名);devServer 热重建路径不合并——
|
|
661
|
-
* dev 的 / 与 /assets/* 直接服务 shell dist,合并产物无人消费。
|
|
662
|
-
*
|
|
663
|
-
* siteBase 非空时(站点挂载在 `/oj` 这类子路径)改写 index.html 的站内绝对
|
|
664
|
-
* URL;apiBase 非空时注入 `window.__OJ_API_BASE__`(API 挂载前缀部署期可配,
|
|
665
|
-
* 见 site-base.ts)。两者都为空则产物与既有行为逐字节一致。
|
|
666
|
-
*/
|
|
667
|
-
function mergeShellSite(distDir: string, siteBase: string, apiBase: string): void {
|
|
668
|
-
const shellDist = resolveShellDist();
|
|
669
|
-
fs.rmSync(distDir, { recursive: true, force: true });
|
|
670
|
-
fs.mkdirSync(path.dirname(distDir), { recursive: true });
|
|
671
|
-
fs.cpSync(shellDist, distDir, { recursive: true });
|
|
672
|
-
|
|
673
|
-
const indexPath = path.join(distDir, "index.html");
|
|
674
|
-
if (normalizeSiteBase(siteBase) !== "/" || apiBase.trim()) {
|
|
675
|
-
fs.writeFileSync(indexPath, rewriteIndexHtml(fs.readFileSync(indexPath, "utf-8"), siteBase, apiBase));
|
|
676
|
-
if (normalizeSiteBase(siteBase) !== "/")
|
|
677
|
-
console.log(`[ojm] 站点 index.html 已按 siteBase=${normalizeSiteBase(siteBase)} 重写(子路径部署)`);
|
|
678
|
-
if (apiBase.trim())
|
|
679
|
-
console.log(`[ojm] API 前缀已注入 index.html:${apiBase.trim().replace(/\/+$/, "")}(apiBase)`);
|
|
680
|
-
}
|
|
681
|
-
console.log(`[ojm] 已合并宿主站点 → ${distDir}`);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* 构建模块工程。
|
|
686
|
-
*
|
|
687
|
-
* 产出(layout.distDir:module-root / new → dist/web;flat → build):
|
|
688
|
-
* <dist>/modules/<name>/<version>/{entry.js, chunk-*.js, *.css}
|
|
689
|
-
* <dist>/modules.json
|
|
690
|
-
*
|
|
691
|
-
* opts.mergeSite 仅 `ojm build` 传 true:清场 + 拷 shell dist 全量;
|
|
692
|
-
* devServer 热重建默认 false,只写模块产物。
|
|
693
|
-
*/
|
|
694
|
-
export async function buildModules(
|
|
695
|
-
projectRoot: string,
|
|
696
|
-
opts: { mergeSite?: boolean } = {},
|
|
697
|
-
): Promise<BuiltModule[]> {
|
|
698
|
-
const layout = resolveLayout(projectRoot);
|
|
699
|
-
const config = await loadSiteConfig(projectRoot);
|
|
700
|
-
// module-root:模块清单由 src/*/web/entry.ts 扫描得出(web.config.ts 仅可选覆盖);
|
|
701
|
-
// 历史布局:仍以 web.config.ts 的 modules 为唯一真源。
|
|
702
|
-
const modules = layout.kind === "module-root"
|
|
703
|
-
? mergeModuleList(scanModules(layout), config.modules, projectRoot)
|
|
704
|
-
: assertModuleList(projectRoot, config.modules);
|
|
705
|
-
const outDir = layout.distDir;
|
|
706
|
-
const baseUrl = config.baseUrl ?? "";
|
|
707
|
-
|
|
708
|
-
if (opts.mergeSite)
|
|
709
|
-
mergeShellSite(outDir, config.siteBase ?? "", config.apiBase ?? "");
|
|
710
|
-
|
|
711
|
-
// P4.5 / C4 / D12:构建前先过版本矩阵门禁,版本漂移直接拒绝
|
|
712
|
-
checkSharedVersions(projectRoot, resolveShellDist());
|
|
713
|
-
|
|
714
|
-
warnUnsharedDeps(projectRoot);
|
|
715
|
-
|
|
716
|
-
const built: BuiltModule[] = [];
|
|
717
|
-
|
|
718
|
-
for (const item of modules) {
|
|
719
|
-
if (item.enabled === false) {
|
|
720
|
-
console.log(`[ojm] 跳过已禁用模块 ${item.name}`);
|
|
721
|
-
continue;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
const entryFile = resolveModuleEntry(projectRoot, item.entry);
|
|
725
|
-
const definition = await readModuleDefinition(entryFile, projectRoot);
|
|
726
|
-
|
|
727
|
-
if (definition.name !== item.name) {
|
|
728
|
-
throw new Error(
|
|
729
|
-
`模块名不一致:${layout.kind === "module-root" ? `src/${item.name}` : "web.config.ts"} 中为 "${item.name}",entry.ts 中为 "${definition.name}"`,
|
|
730
|
-
);
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
const moduleOutDir = path.join(outDir, "modules", definition.name, definition.version);
|
|
734
|
-
const emitted = new Map<string, { fileName: string, type: "chunk" | "asset", isEntry: boolean, isDynamicEntry: boolean }>();
|
|
735
|
-
|
|
736
|
-
const collectChunks: Plugin = {
|
|
737
|
-
name: "ojm:collect-chunks",
|
|
738
|
-
writeBundle(_options, bundle) {
|
|
739
|
-
for (const [fileName, output] of Object.entries(bundle)) {
|
|
740
|
-
emitted.set(fileName, {
|
|
741
|
-
fileName,
|
|
742
|
-
type: output.type,
|
|
743
|
-
isEntry: output.type === "chunk" && output.isEntry,
|
|
744
|
-
isDynamicEntry: output.type === "chunk" && output.isDynamicEntry,
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
},
|
|
748
|
-
};
|
|
749
|
-
|
|
750
|
-
console.log(`[ojm] 构建 ${definition.name}@${definition.version} ...`);
|
|
751
|
-
|
|
752
|
-
await build({
|
|
753
|
-
root: projectRoot,
|
|
754
|
-
logLevel: "warn",
|
|
755
|
-
// §4.1-④:tailwind 模块管线——模块内 import 的 css 经 @tailwindcss/vite
|
|
756
|
-
// 编译,产物由宿主 <link> 注入(R16);模块无 css 入口时插件不产生任何产物
|
|
757
|
-
plugins: [collectChunks, tailwindcss()],
|
|
758
|
-
// Spike A 坑 2:lib 模式不替换 process.env.NODE_ENV,
|
|
759
|
-
// 浏览器顶层求值会抛 process is not defined(风险 R15)
|
|
760
|
-
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
761
|
-
build: {
|
|
762
|
-
outDir: moduleOutDir,
|
|
763
|
-
emptyOutDir: true,
|
|
764
|
-
sourcemap: false,
|
|
765
|
-
/**
|
|
766
|
-
* 模块产物压缩(2026-09-17 由 `false` 改为 `true`)。
|
|
767
|
-
*
|
|
768
|
-
* 本行原为 `false` **且无注释**(同文件其余设置都带 rationale)—— 经查出自
|
|
769
|
-
* 2026-08-31 的调试提交 `1bf3200`,疑似调试期临时值遗留。实测代价极大:
|
|
770
|
-
* 仅这一项就让 planning 的 gzip 从 186,730 → **75,872(−59.4%)**、
|
|
771
|
-
* workitems 88,351 → **40,113(−54.6%)**(同一份产物只做压缩,未改任何配置)。
|
|
772
|
-
* 背景:曾评估「把重复的生成 client 提成共享资产来省体积」,实测 gzip 净变化
|
|
773
|
-
* 仅 −364 B(生成 client 压缩率 6.8%,且重复内容已被同一 bundle 内的 gzip
|
|
774
|
-
* 滑窗吃掉)⇒ **真正的杠杆是压缩,不是去重**(见 oj-module 台账 T54)。
|
|
775
|
-
*
|
|
776
|
-
* ⚠️ **本项只作用于「模块产物」**,与宿主共享资产无关:`shell-dist/assets/*.js`
|
|
777
|
-
* 由 `shell/scripts/build.mts` 自己的 esbuild 构建,且 `esm-exports.ts` 的
|
|
778
|
-
* 导出面解析**依赖资产顶格输出**(其文件头写明「`minify: false` 下顶层语句
|
|
779
|
-
* 一律顶格输出」)⇒ **不要**顺手把共享资产也压了,那会让导入完整性门禁失效。
|
|
780
|
-
*
|
|
781
|
-
* 代价(已知并接受):生产产物栈不可读(`sourcemap: false` 保持不变 —— 开
|
|
782
|
-
* sourcemap 会额外部署 .map 文件,属另一个决策)。
|
|
783
|
-
*/
|
|
784
|
-
minify: true,
|
|
785
|
-
lib: {
|
|
786
|
-
entry: entryFile,
|
|
787
|
-
formats: ["es"],
|
|
788
|
-
fileName: () => "entry.js",
|
|
789
|
-
},
|
|
790
|
-
rolldownOptions: {
|
|
791
|
-
// 只把共享依赖留给宿主 importmap;
|
|
792
|
-
// 相对路径 / 绝对路径是模块自身代码,需要打包
|
|
793
|
-
external: (id: string) => !id.startsWith(".") && !path.isAbsolute(id) && isPrebuiltDep(id),
|
|
794
|
-
output: {
|
|
795
|
-
// P7.x / e2e:模块产物内联动态 import(lazy 页面、i18n 等),
|
|
796
|
-
// 输出为**单文件**。原因:
|
|
797
|
-
// 1. 拆出的 chunk 只能靠 import.meta.url 在「真实 HTTP 服务」下解析,
|
|
798
|
-
// 任何非 HTTP 加载上下文(e2e harness、离线/原生壳、L2 完整性之外的
|
|
799
|
-
// 加载路径)都会 ECONNREFUSED / 404,导致页面空白;
|
|
800
|
-
// 2. 拆出的 lazy chunk 不受 L2 完整性保护(构建期已告警),是模块化
|
|
801
|
-
// 方案的真实完整性缺陷;单文件后整包受 modulepreload+integrity 覆盖;
|
|
802
|
-
// 3. 单文件模块是微前端模块的通用形态,宿主按真实 URL 一次性加载即可,
|
|
803
|
-
// 无孤儿 chunk 依赖。
|
|
804
|
-
codeSplitting: false as any,
|
|
805
|
-
},
|
|
806
|
-
},
|
|
807
|
-
},
|
|
808
|
-
});
|
|
809
|
-
|
|
810
|
-
// 运行期 require 门禁:CJS 依赖 require 了 external 共享包时,rolldown 只留一个浏览器
|
|
811
|
-
// 不可用的 require 桩("Calling `require` for ..."),表现为模块加载即抛错、页面空白。
|
|
812
|
-
// 这类依赖必须移出 bundle 走宿主 importmap(shell 侧有 CJS→ESM 垫片)。
|
|
813
|
-
const builtEntry = path.join(moduleOutDir, "entry.js");
|
|
814
|
-
if (fs.existsSync(builtEntry)) {
|
|
815
|
-
const code = fs.readFileSync(builtEntry, "utf-8");
|
|
816
|
-
if (code.includes("doesn't expose the `require` function")) {
|
|
817
|
-
const specs = [...new Set([...code.matchAll(/ne\("([^"]+)"\)/g)].map(m => m[1]))];
|
|
818
|
-
throw new Error(
|
|
819
|
-
`[ojm] ${definition.name}@${definition.version} 产物含运行期 require(${specs.join(", ") || "?"})`
|
|
820
|
-
+ "——浏览器没有 require,模块加载即抛错。"
|
|
821
|
-
+ "根因:该 CJS 依赖被标为 bundle(自带副本),但它 require 的共享包被 external 了。"
|
|
822
|
-
+ "修复:把它从 SHARED_DEPS 的 bundle 列表移回共享资产,或改用该依赖的 ESM 入口。",
|
|
823
|
-
);
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
const prefix = `${baseUrl}/modules/${definition.name}/${definition.version}/`;
|
|
828
|
-
const chunks: ChunkEntry[] = [];
|
|
829
|
-
const css: string[] = [];
|
|
830
|
-
|
|
831
|
-
for (const info of emitted.values()) {
|
|
832
|
-
if (info.type === "asset") {
|
|
833
|
-
if (info.fileName.endsWith(".css"))
|
|
834
|
-
css.push(`${prefix}${info.fileName}`);
|
|
835
|
-
continue;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
// P7.3:entry 也进 chunks(lazy:false)——宿主 L2 完整性按 chunks[]
|
|
839
|
-
// 注入 modulepreload+integrity,此前 entry 被 isEntry 跳过导致
|
|
840
|
-
// 顶层 integrity 成为死字段(评审 S3:入口恰好不受保护)
|
|
841
|
-
chunks.push({
|
|
842
|
-
url: `${prefix}${info.fileName}`,
|
|
843
|
-
integrity: sha384(path.join(moduleOutDir, info.fileName)),
|
|
844
|
-
lazy: info.isEntry ? false : info.isDynamicEntry,
|
|
845
|
-
});
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
// §4.6:lazy chunk 不受 L2 完整性保护,构建期必须显式提示
|
|
849
|
-
const lazyChunks = chunks.filter(c => c.lazy);
|
|
850
|
-
if (lazyChunks.length > 0) {
|
|
851
|
-
console.warn(
|
|
852
|
-
`[ojm] 模块 "${definition.name}" 含 ${lazyChunks.length} 个 lazy chunk,`
|
|
853
|
-
+ "它们在 L2 完整性档位下不受保护(§4.7 D7):\n"
|
|
854
|
-
+ `${lazyChunks.map(c => ` · ${c.url}`).join("\n")}\n`
|
|
855
|
-
+ "若这些 chunk 也要求完整性,请升级到 L3(Service Worker)或使用逃生通道(§4.7)。",
|
|
856
|
-
);
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// P7.9 / B11:扫描产物中的裸说明符,构建期拦截 importmap 无法解析的
|
|
860
|
-
// 深路径(如 dayjs/plugin/utc——isSharedDep 前缀命中但 importmap 只有
|
|
861
|
-
// 精确键,浏览器必抛 Failed to resolve module specifier);
|
|
862
|
-
// 顺带以真实 import 分析落地 C8 表外依赖告警(不再只扫 package.json 字段)
|
|
863
|
-
assertResolvableSpecifiers(definition.name, moduleOutDir, emitted);
|
|
864
|
-
|
|
865
|
-
built.push({
|
|
866
|
-
name: definition.name,
|
|
867
|
-
version: definition.version,
|
|
868
|
-
enabled: true,
|
|
869
|
-
dependencies: definition.config?.dependencies ?? [],
|
|
870
|
-
peerRuntime: definition.peerRuntime,
|
|
871
|
-
entry: `${prefix}entry.js`,
|
|
872
|
-
integrity: sha384(path.join(moduleOutDir, "entry.js")),
|
|
873
|
-
css,
|
|
874
|
-
chunks,
|
|
875
|
-
routePrefix: resolveRoutePrefix(layout, definition.name),
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
const manifestPath = path.join(outDir, "modules.json");
|
|
880
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
881
|
-
fs.writeFileSync(manifestPath, `${JSON.stringify(built, null, 2)}\n`);
|
|
882
|
-
console.log(`[ojm] 清单已生成 → ${manifestPath}`);
|
|
883
|
-
|
|
884
|
-
return built;
|
|
885
|
-
}
|
|
1
|
+
// 构建编排 facade(2026-11-03 SOLID 拆分,行为零变化,计划 docs/plans/20261103-0930-build-t-solid-refactor)。
|
|
2
|
+
//
|
|
3
|
+
// 原单文件 885 行按职责(SRP)拆为 src/build/ 下四个模块,本文件只保留兼容出口——
|
|
4
|
+
// 既有调用方(dev.ts / cli.ts / bundle.ts / market/bootstrap.ts / build-backend.test.ts,
|
|
5
|
+
// 及经 package exports `./build` 复用的主仓 scripts/build-modules.ts)零改动:
|
|
6
|
+
//
|
|
7
|
+
// module-def.ts 模块元数据读取(esbuild stub 机房 + readModuleDefinition)
|
|
8
|
+
// backend.ts 后端编排(oj build + 别名投影 + 密封值试解 + versions.json 双落点)
|
|
9
|
+
// site-merge.ts 宿主站点合并(shell-dist 全量 + index.html 改写)
|
|
10
|
+
// module-build.ts 前端模块构建(vite/rolldown + 产物完整性 + modules.json)
|
|
11
|
+
//
|
|
12
|
+
// 依赖方向无环:module-build → module-def / site-merge;其余互不依赖。
|
|
13
|
+
export { buildBackend } from "./build/backend";
|
|
14
|
+
export type { BuiltModule, ChunkEntry } from "./build/module-build";
|
|
15
|
+
export { buildModules } from "./build/module-build";
|
|
16
|
+
export { readModuleDefinition } from "./build/module-def";
|