Skip to content
67 changes: 66 additions & 1 deletion packages/core/src/loader/egg_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Request, Response, Application, Context as KoaContext } from '@eggjs/ko
import { pathMatching, type PathMatchingOptions } from '@eggjs/path-matching';
import { isESM, isSupportTypeScript } from '@eggjs/utils';
import type { Logger } from 'egg-logger';
import globby from 'globby';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { isAsyncFunction, isClass, isGeneratorFunction, isObject, isPromise } from 'is-type-of';
import { homedir } from 'node-homedir';
import { now, diff } from 'performance-ms';
Expand All @@ -26,6 +27,12 @@ import { type FileLoaderOptions, CaseStyle, FULLPATH, FileLoader } from './file_
import { ManifestStore, type StartupManifest } from './manifest.ts';

const debug = debuglog('egg/core/loader/egg_loader');
const CONVENTIONAL_MANIFEST_LOADS = [
{ type: 'resolve', path: ['agent'] },
{ type: 'resolve', path: ['app'] },
{ type: 'discover', path: ['app', 'extend'], extensionlessResolve: true },
{ type: 'discover', path: ['app', 'middleware'] },
] as const;

const originalPrototypes: Record<string, unknown> = {
request: Request.prototype,
Expand Down Expand Up @@ -1764,11 +1771,69 @@ export class EggLoader {
* Should be called after all loading phases complete.
*/
generateManifest(): StartupManifest {
return this.manifest.generateManifest({
const manifest = this.manifest.generateManifest({
serverEnv: this.serverEnv,
serverScope: this.serverScope,
typescriptEnabled: isSupportTypeScript(),
});
this.#collectConventionalDynamicFiles(manifest);
return manifest;
}

/**
* metadataOnly startup intentionally skips the agent process, but bundled
* single-mode workers still load agent boot hooks and agent extends later.
* Record convention-based dynamic entry points so the bundle can satisfy
* those runtime lookups without running agent lifecycle hooks at manifest
* generation time.
*/
#collectConventionalDynamicFiles(manifest: StartupManifest): void {
for (const unit of this.getLoadUnits()) {
for (const load of CONVENTIONAL_MANIFEST_LOADS) {
const target = path.join(unit.path, ...load.path);
if (load.type === 'resolve') {
this.#collectConventionResolve(manifest, target);
} else if ('extensionlessResolve' in load && load.extensionlessResolve) {
this.#collectConventionFileResolves(manifest, target);
} else {
this.#collectConventionFileDiscovery(manifest, target);
}
}
}
}

#collectConventionResolve(manifest: StartupManifest, request: string): void {
const requestKey = this.#toManifestRel(request);
if (Object.hasOwn(manifest.resolveCache, requestKey)) return;

const resolved = this.#doResolveModule(request);
manifest.resolveCache[requestKey] = resolved ? this.#toManifestRel(resolved) : null;
}

#collectConventionFileResolves(manifest: StartupManifest, directory: string): void {
const files = this.#collectConventionFileDiscovery(manifest, directory);
for (const file of files) {
const ext = path.extname(file);
if (!ext) continue;
const request = path.join(directory, file.slice(0, -ext.length));
this.#collectConventionResolve(manifest, request);
}
}

#collectConventionFileDiscovery(manifest: StartupManifest, directory: string): string[] {
const dirKey = this.#toManifestRel(directory);
if (Object.hasOwn(manifest.fileDiscovery, dirKey)) return manifest.fileDiscovery[dirKey];

manifest.fileDiscovery[dirKey] =
fs.existsSync(directory) && fs.statSync(directory).isDirectory()
? globby.sync(FileLoader.getDefaultMatch(), { cwd: directory }).sort()
: [];
return manifest.fileDiscovery[dirKey];
}

#toManifestRel(filepath: string): string {
const rel = path.isAbsolute(filepath) ? path.relative(this.options.baseDir, filepath) : filepath;
return rel.replaceAll(path.sep, '/');
}
}

Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/loader/file_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export interface FileLoaderParseItem {
exports: object | Fun;
}

function getDefaultFileLoaderMatch(): string[] {
return isSupportTypeScript() ? ['**/*.(js|ts)', '!**/*.d.ts'] : ['**/*.js'];
}

/**
* Load files from directory to target object.
* @since 1.0.0
Expand All @@ -73,6 +77,10 @@ export class FileLoader {
return EXPORTS;
}

static getDefaultMatch(): string[] {
return getDefaultFileLoaderMatch();
}

readonly options: FileLoaderOptions & Required<Pick<FileLoaderOptions, 'caseStyle'>>;

/**
Expand Down Expand Up @@ -181,7 +189,7 @@ export class FileLoader {
if (files) {
files = Array.isArray(files) ? files : [files];
} else {
files = isSupportTypeScript() ? ['**/*.(js|ts)', '!**/*.d.ts'] : ['**/*.js'];
files = FileLoader.getDefaultMatch();
}

let ignore = this.options.ignore;
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/loader/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ export class ManifestStore {
return cached !== null ? this.#toAbsolute(cached) : undefined;
}

const discovered = this.#resolveFromFileDiscovery(relKey);
if (discovered !== null) {
Comment thread
killagu marked this conversation as resolved.
Outdated
debug('[resolveModule:fileDiscovery] %o => %o', filepath, discovered);
return discovered;
}

const result = fallback();
this.#resolveCacheCollector[relKey] = result !== undefined ? this.#toRelative(result) : null;
return result;
Expand Down Expand Up @@ -343,6 +349,30 @@ export class ManifestStore {
return path.join(this.baseDir, relPath);
}

#resolveFromFileDiscovery(relKey: string): string | undefined | null {
let matchedDir: string | undefined;
for (const dir of Object.keys(this.data.fileDiscovery)) {
if ((relKey === dir || relKey.startsWith(dir + '/')) && (!matchedDir || dir.length > matchedDir.length)) {
matchedDir = dir;
}
}
if (!matchedDir || relKey === matchedDir) return null;

const request = relKey.slice(matchedDir.length + 1);
const files = this.data.fileDiscovery[matchedDir];
const matchedFile = files.find((file) => {
if (file === request) return true;

const ext = path.posix.extname(file);
if (!ext || ext === '.map') return false;

const extensionlessFile = file.slice(0, -ext.length);
return extensionlessFile === request || extensionlessFile === `${request}/index`;
});

return matchedFile ? this.#toAbsolute(path.posix.join(matchedDir, matchedFile)) : undefined;
}

// --- Fingerprint Utilities ---

static #statFingerprint(filepath: string): string | null {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use strict';

module.exports = {
security: {
enable: true,
package: '@eggjs/security',
},
};

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "manifest-dynamic-plugin"
}
31 changes: 31 additions & 0 deletions packages/core/test/loader/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,37 @@ describe('ManifestStore', () => {
}
});

it('should resolve extensionless modules from cached fileDiscovery', async () => {
const baseDir = setupBaseDir();
try {
const collector = ManifestStore.createCollector(baseDir);
collector.globFiles(path.join(baseDir, 'app/extend'), () => ['filter.js', 'nested/helper.ts']);
const manifest = collector.generateManifest({
serverEnv: 'prod',
serverScope: '',
typescriptEnabled: true,
});
await ManifestStore.write(baseDir, manifest);

const store = ManifestStore.load(baseDir, 'prod', '')!;
const filter = store.resolveModule(path.join(baseDir, 'app/extend/filter'), () => {
throw new Error('should not fallback when fileDiscovery can resolve');
});
const nested = store.resolveModule(path.join(baseDir, 'app/extend/nested/helper'), () => {
throw new Error('should not fallback when nested fileDiscovery can resolve');
});
const missing = store.resolveModule(path.join(baseDir, 'app/extend/missing'), () => {
throw new Error('should not fallback when fileDiscovery has scanned the directory');
});

assert.equal(filter, path.join(baseDir, 'app/extend/filter.js'));
assert.equal(nested, path.join(baseDir, 'app/extend/nested/helper.ts'));
assert.equal(missing, undefined);
} finally {
fs.rmSync(baseDir, { recursive: true, force: true });
}
});

it('should call fallback on cache miss and collect result', () => {
const baseDir = setupBaseDir();
try {
Expand Down
79 changes: 79 additions & 0 deletions packages/core/test/loader/manifest_coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,83 @@ describe('ManifestStore coverage: FileLoader getter auto-injects manifest', () =

await testApp.close();
});

it('should collect configured customLoader directories in metadataOnly startup', async () => {
const testApp = createApp('custom-loader', { metadataOnly: true });
try {
await testApp.loader.loadPlugin();
await testApp.loader.loadConfig();
await testApp.loader.loadCustomLoader();

const manifest = testApp.loader.generateManifest();

assert.ok(
manifest.fileDiscovery['app/adapter']?.includes('docker.js'),
'app customLoader directory should be included in manifest fileDiscovery',
);
assert.ok(
manifest.fileDiscovery['app/util']?.includes('sub/fn.js'),
'nested app customLoader directory should be included in manifest fileDiscovery',
);
assert.ok(
manifest.fileDiscovery['app/repository']?.includes('user.js'),
'ctx customLoader directory should be included in manifest fileDiscovery',
);
assert.ok(
manifest.fileDiscovery['app/plugin']?.includes('a.js'),
'app loadunit customLoader directory should be included in manifest fileDiscovery',
);
assert.ok(
manifest.fileDiscovery['config/b/app/plugin']?.includes('b.js'),
'plugin loadunit customLoader directory should be included in manifest fileDiscovery',
);
} finally {
await testApp.close();
}
});

it('should include plugin convention files that metadataOnly app loading does not execute', async () => {
const testApp = createApp('manifest-dynamic-plugin', { metadataOnly: true });
try {
await testApp.loader.loadPlugin();
await testApp.loader.loadConfig();
await testApp.loader.loadApplicationExtend();
await testApp.loader.loadContextExtend();
await testApp.loader.loadRequestExtend();
await testApp.loader.loadResponseExtend();
await testApp.loader.loadHelperExtend();
await testApp.loader.loadCustomApp();
await testApp.loader.loadMiddleware();

const manifest = testApp.loader.generateManifest();

assert.equal(
manifest.resolveCache['node_modules/@eggjs/security/agent'],
'node_modules/@eggjs/security/agent.js',
);
assert.equal(manifest.resolveCache['node_modules/@eggjs/security/app'], 'node_modules/@eggjs/security/app.js');
assert.equal(
manifest.resolveCache['node_modules/@eggjs/security/app/extend/agent'],
'node_modules/@eggjs/security/app/extend/agent.js',
);
assert.equal(
manifest.resolveCache['node_modules/@eggjs/security/app/extend/application'],
'node_modules/@eggjs/security/app/extend/application.js',
);
assert.equal(
manifest.resolveCache['node_modules/@eggjs/security/app/extend/filter'],
'node_modules/@eggjs/security/app/extend/filter.js',
);
assert.ok(
manifest.fileDiscovery['node_modules/@eggjs/security/app/extend']?.includes('filter.js'),
'custom app/extend file should be included in manifest fileDiscovery',
);
assert.ok(
manifest.fileDiscovery['node_modules/@eggjs/security/app/middleware']?.includes('securities.js'),
'security middleware should be included in manifest fileDiscovery',
);
} finally {
await testApp.close();
}
});
});
2 changes: 1 addition & 1 deletion tools/egg-bundler/src/lib/frameworkSpecifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function isPathLikeFrameworkSpecifier(framework: string): boolean {
export function assertFrameworkPackageSpecifier(framework: string): void {
if (!framework || isPathLikeFrameworkSpecifier(framework)) {
throw new Error(
`[@eggjs/egg-bundler] framework must be a package specifier for bundled runtime, got path-like value: ${framework}`,
`[@eggjs/egg-bundler] framework must be a package specifier for bundled runtime, got path-like value: ${JSON.stringify(framework)}`,
);
}
}
Loading