Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions packages/utils/src/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url';
import { debuglog } from 'node:util';

import type { BundleModuleLoader } from '@eggjs/typings';
import type {} from '@eggjs/typings/global';

import { ImportResolveError } from './error/index.ts';

Expand Down Expand Up @@ -422,12 +423,6 @@ export function setSnapshotModuleLoader(loader: SnapshotModuleLoader): void {

export type { BundleModuleLoader } from '@eggjs/typings';

type BundleModuleGlobalThis = typeof globalThis & {
__EGG_BUNDLE_MODULE_LOADER__: BundleModuleLoader | undefined;
};

const bundleModuleGlobalThis = globalThis as BundleModuleGlobalThis;

function normalizeBundleModulePath(filepath: string): string {
return filepath.split(path.win32.sep).join(path.posix.sep);
}
Expand All @@ -443,11 +438,11 @@ function normalizeBundleModulePath(filepath: string): string {
* compatibility.
*/
export function setBundleModuleLoader(loader: BundleModuleLoader | undefined): void {
bundleModuleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = loader;
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = loader;
}

export async function importModule(filepath: string, options?: ImportModuleOptions): Promise<any> {
const _bundleModuleLoader = bundleModuleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__;
const _bundleModuleLoader = globalThis.__EGG_BUNDLE_MODULE_LOADER__;
if (_bundleModuleLoader) {
const hit = _bundleModuleLoader(normalizeBundleModulePath(filepath));
if (hit !== undefined) {
Expand Down
2 changes: 1 addition & 1 deletion plugins/mock/test/mock_service_cluster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe('test/mock_service_cluster.test.ts', () => {
baseDir: getFixtures('demo_mock_service_cluster'),
});
await app.ready();
});
}, 60000);
afterAll(() => app.close());

afterEach(mm.restore);
Expand Down
1 change: 1 addition & 0 deletions tegg/core/loader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@eggjs/core-decorator": "workspace:*",
"@eggjs/metadata": "workspace:*",
"@eggjs/tegg-types": "workspace:*",
"@eggjs/typings": "workspace:*",
"globby": "catalog:",
"is-type-of": "catalog:"
},
Expand Down
37 changes: 24 additions & 13 deletions tegg/core/loader/src/LoaderUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@ import { pathToFileURL } from 'node:url';

import { PrototypeUtil } from '@eggjs/core-decorator';
import type { EggProtoImplClass } from '@eggjs/tegg-types';
import type {} from '@eggjs/typings/global';
import { isClass } from 'is-type-of';

// Guard against poorly mocked module constructors.
const Module = globalThis.module?.constructor?.length > 1 ? globalThis.module.constructor : BuiltinModule;

function createLoadError(filePath: string, e: unknown): Error {
const message = e instanceof Error ? e.message : String(e);
return new Error(`[tegg/loader] load ${filePath} failed: ${message}`, {
cause: e,
});
}

interface LoaderUtilConfig {
extraFilePattern?: string[];
}
Expand Down Expand Up @@ -64,20 +72,23 @@ export class LoaderUtil {

static async loadFile(filePath: string): Promise<EggProtoImplClass[]> {
const originalFilePath = filePath;
if (process.platform === 'win32') {
// convert to file:// url
// avoid windows path issue: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'd:'
filePath = pathToFileURL(filePath).toString();
}
let exports;
let exports: any;
try {
exports = await import(filePath);
} catch (e: any) {
console.trace('[tegg/loader] loadFile %s error:', filePath);
console.error(e);
throw new Error(`[tegg/loader] load ${filePath} failed: ${e.message}`, {
cause: e,
});
exports = globalThis.__EGG_BUNDLE_MODULE_LOADER__?.(originalFilePath.split('\\').join('/'));
} catch (e: unknown) {
throw createLoadError(originalFilePath, e);
}
if (exports == null) {
if (process.platform === 'win32') {
// convert to file:// url
// avoid windows path issue: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'd:'
filePath = pathToFileURL(filePath).toString();
}
try {
exports = await import(filePath);
} catch (e: unknown) {
throw createLoadError(filePath, e);
Comment on lines +88 to +90
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve original path in import-failure errors.

At Line 90, wrapping with filePath can emit a file://... URL on Windows, which makes troubleshooting less clear than the caller-facing path. Use originalFilePath in the error wrapper.

Suggested patch
       try {
         exports = await import(filePath);
       } catch (e: unknown) {
-        throw createLoadError(filePath, e);
+        throw createLoadError(originalFilePath, e);
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exports = await import(filePath);
} catch (e: unknown) {
throw createLoadError(filePath, e);
exports = await import(filePath);
} catch (e: unknown) {
throw createLoadError(originalFilePath, e);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tegg/core/loader/src/LoaderUtil.ts` around lines 88 - 90, The import failure
wrapper is using filePath (which can be a file:// URL on Windows) when calling
createLoadError; change the catch to call createLoadError with originalFilePath
instead so the error uses the caller-facing path. Locate the catch block around
the dynamic import (the line with "exports = await import(filePath);" and the
subsequent throw createLoadError(...)) and replace the second argument from
filePath to originalFilePath; keep the original caught error (e) passed through
unchanged.

}
}
const clazzList: EggProtoImplClass[] = [];
const exportNames = Object.keys(exports);
Expand Down
57 changes: 56 additions & 1 deletion tegg/core/loader/test/Loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import assert from 'node:assert/strict';
import path from 'node:path';

import { PrototypeUtil, SingletonProto } from '@eggjs/core-decorator';
import { EggLoadUnitType } from '@eggjs/metadata';
import { describe, it } from 'vitest';
import type {} from '@eggjs/typings/global';
import { afterEach, describe, it } from 'vitest';

import { LoaderFactory, LoaderUtil } from '../src/index.ts';

describe('core/loader/test/Loader.test.ts', () => {
afterEach(() => {
Comment thread
killagu marked this conversation as resolved.
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = undefined;
LoaderUtil.setConfig({});
});

describe('module loader', () => {
it('should load module', async () => {
const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader');
Expand Down Expand Up @@ -37,6 +44,54 @@ describe('core/loader/test/Loader.test.ts', () => {
const prototypes = await loader.load();
assert.equal(prototypes.length, 1);
});

it('should load pre-bundled files through the bundle module loader', async () => {
class BundledService {}
SingletonProto()(BundledService);
const bundledFile = '/bundle/app/port/manager/UserRoleManager.ts';
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath: string) => {
assert.equal(filepath, bundledFile);
return { BundledService };
};

const prototypes = await LoaderUtil.loadFile(bundledFile);

assert.deepEqual(
prototypes.map((proto) => proto.name),
['BundledService'],
);
assert.equal(PrototypeUtil.getFilePath(BundledService), bundledFile);
});

it('should fall back to dynamic import when the bundle module loader returns null', async () => {
const appRepoFile = path.join(__dirname, './fixtures/modules/module-for-loader/AppRepo.ts');
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = () => null;

const prototypes = await LoaderUtil.loadFile(appRepoFile);

assert.deepEqual(
prototypes.map((proto) => proto.name),
['AppRepo', 'AppRepo2'],
);
});

it('should wrap bundle module loader errors', async () => {
const bundledFile = '/bundle/app/service.ts';
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = () => {
throw 'bundle loader failed';
};

await assert.rejects(
async () => {
await LoaderUtil.loadFile(bundledFile);
},
(err: Error & { cause?: unknown }) => {
assert.equal(err.message, '[tegg/loader] load /bundle/app/service.ts failed: bundle loader failed');
assert.equal(err.cause, 'bundle loader failed');
return true;
},
);
});
});

describe('file has tsc error', () => {
Expand Down
1 change: 1 addition & 0 deletions tools/egg-bundler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
},
"dependencies": {
"@eggjs/core": "workspace:*",
"@eggjs/typings": "workspace:*",
"@utoo/pack": "catalog:",
"execa": "catalog:",
"js-yaml": "catalog:",
Expand Down
6 changes: 2 additions & 4 deletions tools/egg-bundler/src/lib/EntryGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ for (const [key, spec] of __EXTERNAL_SPECS) {
import path from 'node:path';

import { ManifestStore } from '@eggjs/core';
import type {} from '@eggjs/typings/global';
import { startEgg } from ${frameworkSpec};
import * as __frameworkModule from ${frameworkSpec};

Expand Down Expand Up @@ -314,11 +315,8 @@ for (const [appAbsRequest, targetRel] of __APP_RESOLVE_CACHE_ALIASES) {
}
}

const __bundleGlobalThis = globalThis as typeof globalThis & {
__EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown;
};
ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __outputDir));
__bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => {
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => {
return __getBundleMap(filepath);
};

Expand Down
71 changes: 46 additions & 25 deletions tools/egg-bundler/src/lib/ManifestLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,15 @@ interface TeggModuleDescriptor {
decoratedFiles?: string[];
}

interface TeggModuleReference {
name: string;
path: string;
optional?: boolean;
loaderType?: string;
}

interface TeggManifestExtension {
moduleReferences?: TeggModuleReference[];
moduleDescriptors?: TeggModuleDescriptor[];
}

Expand Down Expand Up @@ -401,35 +409,48 @@ export class ManifestLoader {
): Promise<Record<string, unknown>> {
const result: Record<string, unknown> = { ...extensions };
const tegg = extensions?.tegg as TeggManifestExtension | undefined;
if (tegg?.moduleDescriptors) {
result.tegg = {
...tegg,
moduleDescriptors: await Promise.all(
tegg.moduleDescriptors.map(async (desc) => {
if (!path.isAbsolute(desc.unitPath)) return desc;
const real = await this.#realpath(desc.unitPath);
let best: ModuleMapEntry | undefined;
for (const entry of moduleMap) {
if (real === entry.realDir || real.startsWith(entry.realDir + path.sep)) {
best = entry;
break;
}
}
if (!best) {
// keep as relative-to-baseDir form so runtime can resolve via #resolveFromBase
const rel = path.relative(this.#baseDir, real).replaceAll(path.sep, '/');
return { ...desc, unitPath: rel };
}
const rest = real === best.realDir ? '' : real.slice(best.realDir.length + 1);
const unitPath = [best.normalizedDir, rest].filter(Boolean).join('/').replaceAll(path.sep, '/');
return { ...desc, unitPath };
}),
),
};
if (tegg?.moduleReferences || tegg?.moduleDescriptors) {
const normalizedTegg: TeggManifestExtension = { ...tegg };
if (tegg.moduleReferences) {
normalizedTegg.moduleReferences = await Promise.all(
tegg.moduleReferences.map(async (ref) => ({
...ref,
path: await this.#normalizeTeggUnitPath(ref.path, moduleMap),
})),
);
}
if (tegg.moduleDescriptors) {
normalizedTegg.moduleDescriptors = await Promise.all(
tegg.moduleDescriptors.map(async (desc) => ({
...desc,
unitPath: await this.#normalizeTeggUnitPath(desc.unitPath, moduleMap),
})),
);
}
result.tegg = normalizedTegg;
}
return result;
}

async #normalizeTeggUnitPath(unitPath: string, moduleMap: ModuleMapEntry[]): Promise<string> {
if (!path.isAbsolute(unitPath)) return unitPath;
const real = await this.#realpath(unitPath);
let best: ModuleMapEntry | undefined;
for (const entry of moduleMap) {
if (real === entry.realDir || real.startsWith(entry.realDir + path.sep)) {
best = entry;
break;
}
}
if (!best) {
// Keep local app modules relative to baseDir so bundled runtime can
// resolve them under outputDir, and keep descriptor/reference keys equal.
return path.relative(this.#baseDir, real).replaceAll(path.sep, '/');
}
const rest = real === best.realDir ? '' : real.slice(best.realDir.length + 1);
return [best.normalizedDir, rest].filter(Boolean).join('/').replaceAll(path.sep, '/');
}

async #findPackageJsonFromNodeModules(name: string, startDir: string): Promise<string | undefined> {
let dir = startDir;
const nameSegments = name.split('/');
Expand Down
32 changes: 32 additions & 0 deletions tools/egg-bundler/test/EntryGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,38 @@ describe('EntryGenerator', () => {
]);
});

it('includes app/port controller decorated files from tegg manifest descriptors', async () => {
const manifest = makeManifest({
extensions: {
tegg: {
moduleReferences: [
{
name: 'appPort',
path: 'app/port',
},
],
moduleDescriptors: [
{
unitPath: 'app/port',
decoratedFiles: ['controller/HomeController.ts', 'manager/UserRoleManager.ts'],
},
],
},
},
});

const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) });
const result = await gen.generate();
const worker = await fs.readFile(result.workerEntry, 'utf8');

expect(extractImports(worker).map((i) => i.specifier)).toEqual([
'../../app/port/controller/HomeController.ts',
'../../app/port/manager/UserRoleManager.ts',
]);
expect(worker).toContain('"moduleReferences"');
expect(worker).toContain('"path": "app/port"');
});

it('skips resolveCache entries whose value is null', async () => {
const manifest = makeManifest({
resolveCache: {
Expand Down
Loading
Loading