Skip to content

Commit 7ab7db0

Browse files
authored
refactor: mock source loading out of core (#4563)
* Refactor mock source loading out of core * Document mock source loader boundaries
1 parent 126cdd3 commit 7ab7db0

23 files changed

Lines changed: 722 additions & 296 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
## Context
2+
当前 functional 一体化开发链路是:Vite/Rspack dev plugin -> `@midwayjs/mock.createApp()` -> `initializeGlobalApplicationContext()` -> `findProjectEntryFile()` -> `loadModule()` 直接加载 `src/server` 源码。
3+
4+
在 Node 20 + ESM + TypeScript + decorator 场景下,源码直跑需要处理:
5+
- `.js` specifier 对应 `.ts` 源文件
6+
- TypeScript ESM 转译
7+
- decorator 与 `emitDecoratorMetadata`
8+
- bare package import 解析
9+
- dev watch 对临时文件的排除
10+
11+
这些问题仅出现在开发期源码直跑链路,不属于 core 的通用运行时职责。
12+
13+
## Goals / Non-Goals
14+
- Goals:
15+
- 将开发期源码加载兼容逻辑归位到 `@midwayjs/mock`
16+
- 保持 Vite 与 Rspack dev 插件共享同一套 source loader
17+
- 使 `@midwayjs/core.loadModule()` 回到标准加载器职责
18+
- 保持 functional 一体化开发链路在 Node 20 下可用
19+
- Non-Goals:
20+
- 不改变 functional API 用户侧 DSL
21+
- 不切换到新的默认 dev runtime(如 `tsx`
22+
- 不把传统 `dist` 驱动开发链路改为源码直跑
23+
24+
## Decisions
25+
- Decision: dev source loader 归属 `@midwayjs/mock`
26+
- 理由:源码直跑是 mock/dev plugin 的开发期行为,不是 core 通用能力。
27+
28+
- Decision: Vite 与 Rspack 通过 `mock.createApp()` 共享同一 source loader
29+
- 理由:避免两个 dev 入口维护两套 TS/ESM 兼容逻辑。
30+
31+
- Decision: `@midwayjs/core.loadModule()` 只保留标准 `require/import + safeLoad`
32+
- 理由:恢复 core 边界,减少基础层意外回归。
33+
34+
- Decision: 本次先保留当前兼容策略思路,不立即切换到 `tsx`/`ts-node` 作为默认运行时
35+
- 理由:历史上完整 TS runtime 在 Midway/Koa 开发链路里存在启动/重建性能成本,本次优先修正边界而非切换 runtime 路线。
36+
37+
## Risks / Trade-offs
38+
- 风险:从 core 挪出 fallback 逻辑后,mock 内部实现复杂度上升。
39+
- Mitigation: 将 source loader 单独封装,避免散落在 `creator.ts``vite.ts``rspack.ts` 中。
40+
41+
- 风险:Vite/Rspack watcher 与临时产物处理仍可能产生边缘行为。
42+
- Mitigation: 在 mock 层补充 watcher 排除与 Node 20 fixture 回归测试。
43+
44+
- 风险:重新归位时可能误伤已有 commonjs/esm 加载路径。
45+
- Mitigation: 保留 core/mock 定向构建与测试验证,逐步回退 core 逻辑。
46+
47+
## Migration Plan
48+
1.`packages/mock` 引入统一 dev source loader。
49+
2.`mock.createApp()` 在源码入口探测与初始化阶段走 source loader。
50+
3. 让 Vite/Rspack 插件只负责请求桥接与 reload,不再承载源码兼容细节。
51+
4.`core.loadModule()` 回退为纯加载器。
52+
5. 用 Node 20 functional fixture 验证 React/Vue 一体化开发链路。
53+
54+
## Open Questions
55+
- mock source loader 是否以“显式新函数”方式接入,还是通过 `initializeGlobalApplicationContext()` 的可配置 loader hook 接入更合适。
56+
- decorator-heavy 业务在 mock source loader 中是否需要继续保留局部 transpile 策略,还是后续单独评估运行时替换方案。
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Change: Refactor Functional Dev Source Loader Boundary
2+
3+
## Why
4+
当前 functional 前端一体化开发模式通过 `@midwayjs/mock` 在开发期直接加载 `src/server` 源码。为兼容 Node 20 下的 ESM + TypeScript + decorator 场景,临时将源码 fallback/转译逻辑加入了 `@midwayjs/core``loadModule()`
5+
6+
这会把明显属于开发期、且仅在 `mock` 源码直跑链路中需要的逻辑沉入 core 基础加载层,导致职责边界模糊,并增加 core 的维护负担与回归风险。
7+
8+
## What Changes
9+
- 将 functional 一体化开发期的源码加载逻辑从 `@midwayjs/core` 迁移到 `@midwayjs/mock`
10+
-`@midwayjs/mock` 内部引入统一的 dev source loader,供 Vite 与 Rspack 开发插件共享。
11+
-`@midwayjs/core``loadModule()` 恢复为标准模块加载能力,不继续承载开发期源码 fallback/转译细节。
12+
- 为 Node 20 + ESM + TypeScript + decorator + reflect metadata 的 functional 开发链路补充 mock 层回归测试。
13+
14+
## Impact
15+
- Affected specs: `functional-web-routing`
16+
- Affected code:
17+
- `packages/mock/src/creator.ts`
18+
- `packages/mock/src/vite.ts`
19+
- `packages/mock/src/rspack.ts`
20+
- `packages/mock/src/*`(新增 dev source loader)
21+
- `packages/core/src/util/index.ts`
22+
- `packages/mock/test/*`
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
## ADDED Requirements
2+
### Requirement: Development Source Loader Boundary
3+
系统 SHALL 将 functional 一体化开发期的源码加载兼容逻辑限定在 `@midwayjs/mock` 的开发链路内,而不是放入 `@midwayjs/core` 的通用模块加载器。
4+
5+
#### Scenario: mock 持有开发期源码加载逻辑
6+
- **WHEN** 用户通过 Vite 或 Rspack 开发插件启动 functional 一体化项目
7+
- **THEN** 开发期针对 TypeScript/ESM/decorator 的源码加载兼容逻辑由 `@midwayjs/mock` 提供
8+
- **AND** `@midwayjs/core` 不需要感知 dev-only 的源码 fallback 或临时文件机制
9+
10+
#### Scenario: core loadModule 保持标准加载职责
11+
- **WHEN** 运行时调用 `@midwayjs/core` 的通用模块加载能力
12+
- **THEN** 其职责限定为标准 `require/import``safeLoad` 语义
13+
- **AND** 不承担 functional 一体化开发期的源码转译或 specifier fallback 逻辑
14+
15+
### Requirement: Shared Mock Dev Loader for Vite and Rspack
16+
系统 SHALL 让 Vite 与 Rspack 的 functional 开发插件复用同一套 mock source loader,以保证行为一致。
17+
18+
#### Scenario: Vite 与 Rspack 共用 source loader
19+
- **WHEN** 用户分别通过 Vite 或 Rspack 启动 functional 开发环境
20+
- **THEN** 两条链路都通过 `@midwayjs/mock` 的统一 source loader 加载 `src/server` 源码
21+
- **AND** 不为不同 bundler 维护两套独立的 TS/ESM 兼容实现
22+
23+
#### Scenario: 临时产物不触发重复 reload
24+
- **WHEN** mock source loader 在开发期生成内部临时产物或缓存文件
25+
- **THEN** Vite/Rspack watcher 不会把这些内部文件视为业务源码变更
26+
- **AND** 应用不会因内部临时文件产生重复 reload
27+
28+
### Requirement: Functional Dev Runtime Compatibility on Node 20
29+
系统 SHALL 在 Node 20 下支持 functional 一体化开发链路的源码直跑,包括 ESM、TypeScript、本地相对依赖、decorator 与 metadata。
30+
31+
#### Scenario: Node 20 下加载 configuration 与 functional api 源码
32+
- **WHEN** 用户在 Node 20 下启动 functional 一体化开发环境
33+
- **THEN** `src/server/configuration.ts``src/server/index.ts``src/server/api/**/*.ts` 可被成功加载
34+
- **AND** 本地相对依赖与 bare package import 能保持可解析
35+
36+
#### Scenario: Node 20 下保留 decorator metadata 行为
37+
- **WHEN** 开发期源码模块包含 Midway 装饰器、属性注入或 `reflect-metadata` 相关语义
38+
- **THEN** 其运行时行为与标准 TypeScript 编译结果保持兼容
39+
- **AND** 不因开发期源码加载兼容逻辑导致 metadata 丢失或装饰器语义异常
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## 1. Design
2+
- [x] 1.1 明确 `@midwayjs/core``@midwayjs/mock` 在开发期源码加载中的职责边界
3+
- [x] 1.2 设计 `mock` 内部共享 dev source loader,覆盖 Vite 与 Rspack 两条开发链路
4+
5+
## 2. Implementation
6+
- [x] 2.1 在 `packages/mock` 中实现 dev source loader,并封装 ESM + TypeScript + decorator 开发期兼容逻辑
7+
- [x] 2.2 调整 `packages/mock/src/creator.ts`,在源码入口探测与应用初始化阶段使用 mock source loader
8+
- [x] 2.3 调整 `packages/mock/src/vite.ts``packages/mock/src/rspack.ts` 以复用统一 source loader,并维持 watcher 行为稳定
9+
- [x] 2.4 将 `packages/core/src/util/index.ts` 回退为标准模块加载实现,移除 dev-only fallback/临时文件逻辑
10+
11+
## 3. Validation
12+
- [x] 3.1 为 `@midwayjs/mock` 新增 Node 20 + ESM + TypeScript + decorator fixture 回归测试
13+
- [x] 3.2 验证 Vite functional dev 场景在 Node 20 下可启动且不会因临时文件造成重复 reload
14+
- [x] 3.3 运行 `pnpm -C packages/core build`
15+
- [x] 3.4 运行 `pnpm -C packages/mock build`
16+
- [x] 3.5 运行相关 mock/core 定向测试
17+
- [x] 3.6 运行 `openspec validate refactor-functional-dev-source-loader-boundary --strict --no-interactive`

packages/core/src/common/fileDetector.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import {
66
import { Types } from '../util/types';
77
import { run } from '@midwayjs/glob';
88
import { MidwayDuplicateClassNameError } from '../error';
9-
import { DEFAULT_PATTERN, IGNORE_PATTERN } from '../constants';
9+
import {
10+
DEFAULT_PATTERN,
11+
IGNORE_PATTERN,
12+
MODULE_LOADER_KEY,
13+
} from '../constants';
1014
import { loadModule } from '../util';
1115
import { DecoratorManager } from '../decorator';
1216
import { debuglog } from 'util';
@@ -114,6 +118,13 @@ export class CommonJSFileDetector extends AbstractFileDetector<{
114118

115119
async loadAsync(container: IMidwayGlobalContainer, namespace: string) {
116120
this.options = this.options || {};
121+
// When mock injects a dev-only source loader, detector scans must reuse it
122+
// instead of falling back to the generic core loader.
123+
const moduleLoader: typeof loadModule = container.hasObject(
124+
MODULE_LOADER_KEY
125+
)
126+
? container.getObject(MODULE_LOADER_KEY)
127+
: loadModule;
117128
const loadDirs = [].concat(
118129
this.options.loadDir ?? container.get('baseDir')
119130
);
@@ -171,7 +182,7 @@ export class CommonJSFileDetector extends AbstractFileDetector<{
171182
? `${Date.now()}_${Math.random()}`
172183
: undefined;
173184
}
174-
const exports = await loadModule(file, {
185+
const exports = await moduleLoader(file, {
175186
loadMode: 'esm',
176187
importQuery: currentImportQuery,
177188
});

packages/core/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const REQUEST_CTX_UNIQUE_KEY = '_midway_ctx_unique_key';
1818

1919
export const ASYNC_CONTEXT_KEY = Symbol('ASYNC_CONTEXT_KEY');
2020
export const ASYNC_CONTEXT_MANAGER_KEY = 'MIDWAY_ASYNC_CONTEXT_MANAGER_KEY';
21+
export const MODULE_LOADER_KEY = 'MIDWAY_MODULE_LOADER_KEY';
2122

2223
export const DEFAULT_PATTERN = [
2324
'**/**.ts',

packages/core/src/interface.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,20 @@ export type IMidwayApplication<
11001100

11011101
export type ModuleLoadType = 'commonjs' | 'esm';
11021102

1103+
export interface ModuleLoadOptions {
1104+
enableCache?: boolean;
1105+
loadMode?: ModuleLoadType;
1106+
safeLoad?: boolean;
1107+
warnOnLoadError?: boolean;
1108+
extraModuleRoot?: string[];
1109+
importQuery?: string;
1110+
}
1111+
1112+
export type ModuleLoader = (
1113+
p: string,
1114+
options?: ModuleLoadOptions
1115+
) => Promise<any>;
1116+
11031117
export interface IMidwayBootstrapOptions {
11041118
baseDir?: string;
11051119
appDir?: string;
@@ -1113,6 +1127,9 @@ export interface IMidwayBootstrapOptions {
11131127
| Record<string, any>;
11141128
asyncContextManager?: AsyncContextManager;
11151129
loggerFactory?: LoggerFactory<any, any>;
1130+
// Internal hook for callers like @midwayjs/mock that need a custom module
1131+
// loader during bootstrap. Core should keep loadModule() itself generic.
1132+
moduleLoader?: ModuleLoader;
11161133
}
11171134

11181135
export interface IConfigurationOptions {

packages/core/src/setup.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { MidwayTraceService } from './service/traceService';
2525
import { ComponentConfigurationLoader } from './context/componentLoader';
2626
import { findProjectEntryFile, findProjectEntryFileSync } from './util';
2727
import { AsyncLocalStorageContextManager } from './common/asyncContextManager';
28+
import { MODULE_LOADER_KEY } from './constants';
2829
import {
2930
MidwayInitializerPerformanceManager,
3031
MidwayPerformanceManager,
@@ -199,6 +200,14 @@ export async function prepareGlobalApplicationContextAsync(
199200
// register baseDir and appDir
200201
applicationContext.registerObject('baseDir', baseDir);
201202
applicationContext.registerObject('appDir', appDir);
203+
if (globalOptions.moduleLoader) {
204+
// Keep the custom loader on the container so later module discovery
205+
// phases (for example file detectors) can follow the same load strategy.
206+
applicationContext.registerObject(
207+
MODULE_LOADER_KEY,
208+
globalOptions.moduleLoader
209+
);
210+
}
202211

203212
if (!globalOptions.asyncContextManager) {
204213
globalOptions.asyncContextManager = new AsyncLocalStorageContextManager();
@@ -224,7 +233,12 @@ export async function prepareGlobalApplicationContextAsync(
224233
// set entry file
225234
globalOptions.imports = [
226235
...(globalOptions.imports ?? []),
227-
await findProjectEntryFile(appDir, baseDir, globalOptions.moduleLoadType),
236+
await findProjectEntryFile(
237+
appDir,
238+
baseDir,
239+
globalOptions.moduleLoadType,
240+
globalOptions.moduleLoader
241+
),
228242
];
229243

230244
MidwayInitializerPerformanceManager.markEnd(
@@ -366,6 +380,14 @@ export function prepareGlobalApplicationContext(
366380
// register baseDir and appDir
367381
applicationContext.registerObject('baseDir', baseDir);
368382
applicationContext.registerObject('appDir', appDir);
383+
if (globalOptions.moduleLoader) {
384+
// Keep the custom loader on the container so later module discovery
385+
// phases (for example file detectors) can follow the same load strategy.
386+
applicationContext.registerObject(
387+
MODULE_LOADER_KEY,
388+
globalOptions.moduleLoader
389+
);
390+
}
369391

370392
if (!globalOptions.asyncContextManager) {
371393
globalOptions.asyncContextManager = new AsyncLocalStorageContextManager();

0 commit comments

Comments
 (0)