Skip to content

Commit 0dec2c9

Browse files
authored
feat(core): add loader fs abstraction (#5936)
## Summary - add LoaderFS and RealLoaderFS as the loader-facing filesystem boundary - thread loaderFS through EggLoader/FileLoader/ContextLoader paths without changing default runtime behavior - add targeted loader tests and wiki notes for the exported API ## Tests - pnpm exec vitest run packages/core/test/loader/file_loader.test.ts packages/core/test/loader/context_loader.test.ts packages/core/test/loader/loader_fs.test.ts packages/core/test/loader/egg_loader.test.ts --testTimeout 20000 --hookTimeout 20000 - pnpm --filter @eggjs/core run typecheck - pnpm exec oxfmt --check packages/core/src/loader/loader_fs.ts packages/core/src/loader/file_loader.ts packages/core/src/loader/egg_loader.ts packages/core/src/index.ts packages/core/test/loader/loader_fs.test.ts packages/core/test/loader/egg_loader.test.ts wiki/packages/core.md wiki/index.md wiki/log.md - pnpm exec oxlint --type-aware --type-check --quiet packages/core/src/loader/loader_fs.ts packages/core/src/loader/file_loader.ts packages/core/src/loader/egg_loader.ts packages/core/test/loader/loader_fs.test.ts packages/core/test/loader/egg_loader.test.ts (0 errors; 7 existing warnings in egg_loader.ts) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a loader filesystem abstraction to allow custom filesystem implementations. * Loaders can accept an injected filesystem via options and forward it during loading. * Core package now re-exports and exposes the LoaderFS boundary. * **Documentation** * Added Core Package docs describing the LoaderFS boundary and default implementation. * Added changelog entry documenting the filesystem boundary. * **Tests** * Added tests for the default filesystem implementation and loader integration with a custom filesystem. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 0ebb28e commit 0dec2c9

11 files changed

Lines changed: 225 additions & 12 deletions

File tree

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './singleton.ts';
1111
export * from './loader/egg_loader.ts';
1212
export * from './loader/file_loader.ts';
1313
export * from './loader/context_loader.ts';
14+
export * from './loader/loader_fs.ts';
1415
export * from './loader/manifest.ts';
1516
export * from './utils/sequencify.ts';
1617
export * from './utils/timing.ts';

packages/core/src/loader/egg_loader.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { sequencify } from '../utils/sequencify.ts';
2424
import { Timing } from '../utils/timing.ts';
2525
import { type ContextLoaderOptions, ContextLoader } from './context_loader.ts';
2626
import { type FileLoaderOptions, CaseStyle, FULLPATH, FileLoader } from './file_loader.ts';
27+
import { RealLoaderFS, type LoaderFS } from './loader_fs.ts';
2728
import { ManifestStore, type StartupManifest } from './manifest.ts';
2829

2930
const debug = debuglog('egg/core/loader/egg_loader');
@@ -57,6 +58,8 @@ export interface EggLoaderOptions {
5758
plugins?: Record<string, EggPluginInfo>;
5859
/** Skip lifecycle hooks, only trigger loadMetadata for manifest generation */
5960
metadataOnly?: boolean;
61+
/** Loader-facing filesystem abstraction */
62+
loaderFS?: LoaderFS;
6063
}
6164

6265
export type EggDirInfoType = 'app' | 'plugin' | 'framework';
@@ -79,6 +82,7 @@ export class EggLoader {
7982
dirs?: EggDirInfo[];
8083
/** Startup manifest — loaded from cache or collecting for generation */
8184
readonly manifest: ManifestStore;
85+
readonly loaderFS: LoaderFS;
8286

8387
/**
8488
* @class
@@ -91,6 +95,7 @@ export class EggLoader {
9195
*/
9296
constructor(options: EggLoaderOptions) {
9397
this.options = options;
98+
this.loaderFS = this.options.loaderFS ?? new RealLoaderFS();
9499
assert(fs.existsSync(this.options.baseDir), `${this.options.baseDir} not exists`);
95100
assert(this.options.app, 'options.app is required');
96101
assert(this.options.logger, 'options.logger is required');
@@ -1653,6 +1658,7 @@ export class EggLoader {
16531658
target,
16541659
inject: this.app,
16551660
manifest: this.manifest,
1661+
loaderFS: options?.loaderFS ?? this.loaderFS,
16561662
};
16571663

16581664
const timingKey = `Load "${String(property)}" to Application`;
@@ -1679,6 +1685,7 @@ export class EggLoader {
16791685
property,
16801686
inject: this.app,
16811687
manifest: this.manifest,
1688+
loaderFS: options?.loaderFS ?? this.loaderFS,
16821689
};
16831690

16841691
const timingKey = `Load "${String(property)}" to Context`;

packages/core/src/loader/file_loader.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import assert from 'node:assert';
2-
import fs from 'node:fs';
32
import path from 'node:path';
43
import { debuglog } from 'node:util';
54

65
import { isSupportTypeScript } from '@eggjs/utils';
7-
import globby from 'globby';
86
import { isClass, isGeneratorFunction, isAsyncFunction, isPrimitive } from 'is-type-of';
97

10-
import utils, { type Fun } from '../utils/index.ts';
8+
import utils from '../utils/index.ts';
9+
import { RealLoaderFS, type LoaderFS } from './loader_fs.ts';
1110
import type { ManifestStore } from './manifest.ts';
1211

1312
const debug = debuglog('egg/core/file_loader');
@@ -52,14 +51,18 @@ export interface FileLoaderOptions {
5251
lowercaseFirst?: boolean;
5352
/** Startup manifest for caching globby scans and collecting results */
5453
manifest?: ManifestStore;
54+
/** Loader-facing filesystem abstraction */
55+
loaderFS?: LoaderFS;
5556
}
5657

5758
export interface FileLoaderParseItem {
5859
fullpath: string;
5960
properties: string[];
60-
exports: object | Fun;
61+
exports: unknown;
6162
}
6263

64+
type NormalizedFileLoaderOptions = FileLoaderOptions & Required<Pick<FileLoaderOptions, 'caseStyle' | 'loaderFS'>>;
65+
6366
function getDefaultFileLoaderMatch(): string[] {
6467
return isSupportTypeScript() ? ['**/*.(js|ts)', '!**/*.d.ts'] : ['**/*.js'];
6568
}
@@ -81,7 +84,7 @@ export class FileLoader {
8184
return getDefaultFileLoaderMatch();
8285
}
8386

84-
readonly options: FileLoaderOptions & Required<Pick<FileLoaderOptions, 'caseStyle'>>;
87+
readonly options: NormalizedFileLoaderOptions;
8588

8689
/**
8790
* @class
@@ -108,6 +111,7 @@ export class FileLoader {
108111
caseStyle: CaseStyle.camel,
109112
call: true,
110113
override: false,
114+
loaderFS: new RealLoaderFS(),
111115
...options,
112116
};
113117

@@ -210,12 +214,12 @@ export class FileLoader {
210214
for (const directory of directories) {
211215
const manifest = this.options.manifest;
212216
const filepaths = manifest
213-
? manifest.globFiles(directory, () => globby.sync(files, { cwd: directory }))
214-
: globby.sync(files, { cwd: directory });
217+
? manifest.globFiles(directory, () => this.options.loaderFS.glob(files, { cwd: directory }))
218+
: this.options.loaderFS.glob(files, { cwd: directory });
215219
debug('[parse] files: %o, cwd: %o => %o', files, directory, filepaths);
216220
for (const filepath of filepaths) {
217221
const fullpath = path.join(directory, filepath);
218-
if (!fs.statSync(fullpath).isFile()) continue;
222+
if (!this.options.loaderFS.stat(fullpath).isFile()) continue;
219223
if (filepath.endsWith('.js')) {
220224
const filepathTs = filepath.replace(/\.js$/, '.ts');
221225
if (filepaths.includes(filepathTs)) {
@@ -266,8 +270,8 @@ function getProperties(filepath: string, caseStyle: CaseStyle | CaseStyleFunctio
266270

267271
// Get exports from filepath
268272
// If exports is null/undefined, it will be ignored
269-
async function getExports(fullpath: string, options: FileLoaderOptions, pathName: string): Promise<any> {
270-
let exports = await utils.loadFile(fullpath);
273+
async function getExports(fullpath: string, options: NormalizedFileLoaderOptions, pathName: string): Promise<unknown> {
274+
let exports = await options.loaderFS.loadFile(fullpath);
271275
// process exports as you like
272276
if (options.initializer) {
273277
exports = options.initializer(exports, { path: fullpath, pathName });
@@ -293,7 +297,8 @@ async function getExports(fullpath: string, options: FileLoaderOptions, pathName
293297
// return {};
294298
// }
295299
if (options.call && typeof exports === 'function') {
296-
exports = exports(options.inject);
300+
const callableExports = exports as (inject?: FileLoaderOptions['inject']) => unknown;
301+
exports = callableExports(options.inject);
297302
if (exports !== null && exports !== undefined) {
298303
return exports;
299304
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import fs, { type Stats } from 'node:fs';
2+
3+
import globby from 'globby';
4+
import { readJSONSync } from 'utility';
5+
6+
import utils from '../utils/index.ts';
7+
8+
export type LoaderFSGlobOptions = globby.GlobbyOptions;
9+
10+
export interface LoaderFS {
11+
exists(filepath: string): boolean;
12+
stat(filepath: string): Stats;
13+
realpath(filepath: string): string;
14+
readJSON<T = unknown>(filepath: string): T;
15+
glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[];
16+
loadFile(filepath: string): Promise<unknown>;
17+
}
18+
19+
export class RealLoaderFS implements LoaderFS {
20+
exists(filepath: string): boolean {
21+
return fs.existsSync(filepath);
22+
}
23+
24+
stat(filepath: string): Stats {
25+
return fs.statSync(filepath);
26+
}
27+
28+
realpath(filepath: string): string {
29+
return fs.realpathSync(filepath);
30+
}
31+
32+
readJSON<T = unknown>(filepath: string): T {
33+
return readJSONSync(filepath) as T;
34+
}
35+
36+
glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[] {
37+
return globby.sync(patterns, options);
38+
}
39+
40+
async loadFile(filepath: string): Promise<unknown> {
41+
return utils.loadFile(filepath);
42+
}
43+
}

packages/core/test/__snapshots__/index.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ exports[`should expose properties 1`] = `
1919
"KoaResponse",
2020
"Lifecycle",
2121
"ManifestStore",
22+
"RealLoaderFS",
2223
"Request",
2324
"Response",
2425
"Router",

packages/core/test/loader/egg_loader.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import { getPlugins } from '@eggjs/utils';
66
import { mm } from 'mm';
77
import { describe, it, beforeAll, afterAll, afterEach } from 'vitest';
88

9-
import { EggLoader } from '../../src/index.js';
9+
import {
10+
ContextLoader,
11+
EggLoader,
12+
FileLoader,
13+
RealLoaderFS,
14+
type EggLoaderOptions,
15+
type LoaderFS,
16+
} from '../../src/index.js';
1017
import { createApp, getFilepath, type Application } from '../helper.js';
1118

1219
describe('test/loader/egg_loader.test.ts', () => {
@@ -109,6 +116,38 @@ describe('test/loader/egg_loader.test.ts', () => {
109116
assert(Reflect.get(app.context, prop).user);
110117
});
111118

119+
it('should pass loaderFS to loadToApp and loadToContext', async () => {
120+
const baseDir = getFilepath('load_to_app');
121+
const loaderFS = new RealLoaderFS();
122+
const loaderApp = { context: {} } as EggLoaderOptions['app'];
123+
const loader = new EggLoader({
124+
env: 'unittest',
125+
baseDir,
126+
app: loaderApp,
127+
logger: app.logger,
128+
loaderFS,
129+
});
130+
const passedLoaderFS: LoaderFS[] = [];
131+
132+
mm(FileLoader.prototype, 'load', async function (this: FileLoader) {
133+
passedLoaderFS.push(this.options.loaderFS);
134+
return {};
135+
});
136+
mm(ContextLoader.prototype, 'load', async function (this: ContextLoader) {
137+
passedLoaderFS.push(this.options.loaderFS);
138+
return {};
139+
});
140+
141+
try {
142+
await loader.loadToApp(path.join(baseDir, 'app/model'), 'model');
143+
await loader.loadToContext(path.join(baseDir, 'app/service'), 'service');
144+
145+
assert.deepEqual(passedLoaderFS, [loaderFS, loaderFS]);
146+
} finally {
147+
mm.restore();
148+
}
149+
});
150+
112151
describe('resolveModule with outDir', () => {
113152
afterEach(mm.restore);
114153

packages/core/test/loader/file_loader.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,33 @@ import yaml from 'js-yaml';
66
import { describe, it, expect } from 'vitest';
77

88
import { FileLoader, CaseStyle } from '../../src/loader/file_loader.ts';
9+
import { RealLoaderFS, type LoaderFSGlobOptions } from '../../src/loader/loader_fs.ts';
10+
import { ManifestStore } from '../../src/loader/manifest.ts';
911
import { getFilepath } from '../helper.ts';
1012

1113
const dirBase = getFilepath('load_dirs');
1214

15+
class RecordingLoaderFS extends RealLoaderFS {
16+
readonly globCalls: Array<{ patterns: string | string[]; cwd: string | undefined }> = [];
17+
readonly statCalls: string[] = [];
18+
readonly loadFileCalls: string[] = [];
19+
20+
glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[] {
21+
this.globCalls.push({ patterns, cwd: options?.cwd ? String(options.cwd) : undefined });
22+
return super.glob(patterns, options);
23+
}
24+
25+
stat(filepath: string) {
26+
this.statCalls.push(filepath);
27+
return super.stat(filepath);
28+
}
29+
30+
async loadFile(filepath: string): Promise<unknown> {
31+
this.loadFileCalls.push(filepath);
32+
return super.loadFile(filepath);
33+
}
34+
}
35+
1336
describe('test/loader/file_loader.test.ts', () => {
1437
it('should load files with package.json#exports', async () => {
1538
const directory = path.join(__dirname, '../../../../plugins/mock/src/app/middleware');
@@ -397,4 +420,20 @@ describe('test/loader/file_loader.test.ts', () => {
397420
}).load();
398421
assert.deepEqual(Object.keys(target), ['arr', 'class']);
399422
});
423+
424+
it('should use loaderFS for discovery, stat and loadFile', async () => {
425+
const target: Record<string, unknown> = {};
426+
const loaderFS = new RecordingLoaderFS();
427+
await new FileLoader({
428+
directory: path.join(dirBase, 'services'),
429+
target,
430+
loaderFS,
431+
manifest: ManifestStore.createCollector(dirBase),
432+
}).load();
433+
434+
assert(target.fooService);
435+
assert(loaderFS.globCalls.some((call) => call.cwd === path.join(dirBase, 'services')));
436+
assert(loaderFS.statCalls.some((filepath) => filepath.endsWith(path.join('services', 'foo_service.js'))));
437+
assert(loaderFS.loadFileCalls.some((filepath) => filepath.endsWith(path.join('services', 'foo_service.js'))));
438+
});
400439
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
5+
import globby from 'globby';
6+
import { describe, it } from 'vitest';
7+
8+
import { RealLoaderFS } from '../../src/loader/loader_fs.ts';
9+
import utils from '../../src/utils/index.ts';
10+
import { getFilepath } from '../helper.ts';
11+
12+
describe('test/loader/loader_fs.test.ts', () => {
13+
const loaderFS = new RealLoaderFS();
14+
const baseDir = getFilepath('loadfile');
15+
16+
it('should wrap exists/stat/realpath with node fs behavior', () => {
17+
const filepath = path.join(baseDir, 'object.js');
18+
19+
assert.equal(loaderFS.exists(filepath), fs.existsSync(filepath));
20+
assert.equal(loaderFS.exists(path.join(baseDir, 'not-exists.js')), false);
21+
assert.equal(loaderFS.stat(filepath).isFile(), fs.statSync(filepath).isFile());
22+
assert.equal(loaderFS.realpath(baseDir), fs.realpathSync(baseDir));
23+
});
24+
25+
it('should wrap readJSON/glob/loadFile with current loader behavior', async () => {
26+
const packagePath = path.join(baseDir, 'package.json');
27+
const patterns = ['*.js', '!null.js'];
28+
29+
assert.deepEqual(await loaderFS.readJSON(packagePath), JSON.parse(fs.readFileSync(packagePath, 'utf8')));
30+
assert.deepEqual(loaderFS.glob(patterns, { cwd: baseDir }).sort(), globby.sync(patterns, { cwd: baseDir }).sort());
31+
assert.deepEqual(
32+
await loaderFS.loadFile(path.join(baseDir, 'object.js')),
33+
await utils.loadFile(path.join(baseDir, 'object.js')),
34+
);
35+
});
36+
});

wiki/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Read this file before exploring raw sources.
1818

1919
## Packages
2020

21+
- [Core Package](./packages/core.md) - Loader, lifecycle, and application core primitives used by Egg runtime packages.
2122
- [Egg Bundler](./packages/egg-bundler.md) - Tooling package that bundles Egg applications and backs `egg-bin bundle`.
2223
- [Onerror Plugin](./packages/onerror.md) - Default Egg error-handling plugin and configurable response negotiation layer.
2324
- [Typings Package](./packages/typings.md) - Shared TypeScript type surface for cross-package Egg typings.

wiki/log.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
Dates use the workspace-local Asia/Shanghai calendar date.
44

5+
## [2026-05-07] package | document core LoaderFS boundary
6+
7+
- sources touched: `packages/core/src/index.ts`, `packages/core/src/loader/loader_fs.ts`, `packages/core/src/loader/file_loader.ts`, `packages/core/src/loader/context_loader.ts`, `packages/core/src/loader/egg_loader.ts`
8+
- pages updated: `wiki/index.md`, `wiki/log.md`, `wiki/packages/core.md`
9+
- note: Recorded `LoaderFS` as the minimal loader filesystem boundary and `RealLoaderFS` as the default implementation for existing non-bundled behavior.
10+
511
## [2026-05-06] package | sync bundled runtime support changes
612

713
- sources touched: `tools/egg-bundler/src/lib/ExternalsResolver.ts`, `packages/utils/src/import.ts`, `plugins/onerror/src/lib/onerror.ts`

0 commit comments

Comments
 (0)