diff --git a/packages/core/package.json b/packages/core/package.json
index def05f274f..5cb1429b6a 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -43,6 +43,7 @@
"@eggjs/koa": "workspace:*",
"@eggjs/path-matching": "workspace:*",
"@eggjs/router": "workspace:*",
+ "@eggjs/typings": "workspace:*",
"@eggjs/utils": "workspace:*",
"egg-logger": "catalog:",
"get-ready": "catalog:",
diff --git a/packages/core/src/global.d.ts b/packages/core/src/global.d.ts
new file mode 100644
index 0000000000..ce0eed427e
--- /dev/null
+++ b/packages/core/src/global.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/typings/package.json b/packages/typings/package.json
new file mode 100644
index 0000000000..21a6c272b5
--- /dev/null
+++ b/packages/typings/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@eggjs/typings",
+ "version": "1.0.0-beta.9",
+ "description": "Shared typings for Egg projects",
+ "keywords": [
+ "egg",
+ "typescript",
+ "typings"
+ ],
+ "homepage": "https://github.com/eggjs/egg/tree/next/packages/typings",
+ "bugs": {
+ "url": "https://github.com/eggjs/egg/issues"
+ },
+ "license": "MIT",
+ "author": "fengmk2 ",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/eggjs/egg.git",
+ "directory": "packages/typings"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": "./src/index.ts",
+ "./package.json": "./package.json"
+ },
+ "publishConfig": {
+ "access": "public",
+ "exports": {
+ ".": "./dist/index.js",
+ "./package.json": "./package.json"
+ }
+ },
+ "scripts": {
+ "typecheck": "tsgo --noEmit"
+ },
+ "devDependencies": {
+ "@eggjs/tsconfig": "workspace:*",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=22.18.0"
+ }
+}
diff --git a/packages/typings/src/index.ts b/packages/typings/src/index.ts
new file mode 100644
index 0000000000..594db7a3cb
--- /dev/null
+++ b/packages/typings/src/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Module loader for bundled egg apps. Called with the raw `importModule()`
+ * filepath (posix-normalized) before `importResolve`, so bundled apps can
+ * serve modules that no longer exist on disk. Return `undefined` to fall
+ * through to the standard import path.
+ */
+export type BundleModuleLoader = (filepath: string) => unknown;
+
+declare global {
+ var __EGG_BUNDLE_MODULE_LOADER__: BundleModuleLoader | undefined;
+}
diff --git a/packages/typings/tsconfig.json b/packages/typings/tsconfig.json
new file mode 100644
index 0000000000..4082f16a5d
--- /dev/null
+++ b/packages/typings/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../../tsconfig.json"
+}
diff --git a/packages/typings/tsdown.config.ts b/packages/typings/tsdown.config.ts
new file mode 100644
index 0000000000..6dfb63bdb8
--- /dev/null
+++ b/packages/typings/tsdown.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'tsdown';
+
+export default defineConfig({
+ entry: {
+ index: 'src/index.ts',
+ },
+});
diff --git a/packages/utils/package.json b/packages/utils/package.json
index 9bcc0d82c8..5912c87588 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -35,7 +35,9 @@
"scripts": {
"typecheck": "tsgo --noEmit"
},
- "dependencies": {},
+ "dependencies": {
+ "@eggjs/typings": "workspace:*"
+ },
"devDependencies": {
"coffee": "catalog:",
"mm": "catalog:",
diff --git a/packages/utils/src/import.ts b/packages/utils/src/import.ts
index 8a6e036e21..e506c06c6d 100644
--- a/packages/utils/src/import.ts
+++ b/packages/utils/src/import.ts
@@ -4,6 +4,8 @@ import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
import { debuglog } from 'node:util';
+import type { BundleModuleLoader } from '@eggjs/typings';
+
import { ImportResolveError } from './error/index.ts';
const debug = debuglog('egg/utils/import');
@@ -394,7 +396,32 @@ export function setSnapshotModuleLoader(loader: SnapshotModuleLoader): void {
isESM = false;
}
+export type { BundleModuleLoader } from '@eggjs/typings';
+
+function normalizeBundleModulePath(filepath: string): string {
+ return filepath.split(path.win32.sep).join(path.posix.sep);
+}
+
+/**
+ * Register a bundle module loader. Uses globalThis so that bundled and
+ * external copies of @eggjs/utils share the same loader.
+ */
+export function setBundleModuleLoader(loader: BundleModuleLoader | undefined): void {
+ globalThis.__EGG_BUNDLE_MODULE_LOADER__ = loader;
+}
+
export async function importModule(filepath: string, options?: ImportModuleOptions): Promise {
+ const _bundleModuleLoader = globalThis.__EGG_BUNDLE_MODULE_LOADER__;
+ if (_bundleModuleLoader) {
+ const hit = _bundleModuleLoader(normalizeBundleModulePath(filepath));
+ if (hit !== undefined) {
+ let obj = hit as any;
+ if (obj?.default?.__esModule === true && 'default' in obj.default) obj = obj.default;
+ if (options?.importDefaultOnly && obj && typeof obj === 'object' && 'default' in obj) obj = obj.default;
+ return obj;
+ }
+ }
+
const moduleFilePath = importResolve(filepath, options);
if (_snapshotModuleLoader) {
diff --git a/packages/utils/test/__snapshots__/index.test.ts.snap b/packages/utils/test/__snapshots__/index.test.ts.snap
index a7d626f650..40c81c80d6 100644
--- a/packages/utils/test/__snapshots__/index.test.ts.snap
+++ b/packages/utils/test/__snapshots__/index.test.ts.snap
@@ -19,6 +19,7 @@ exports[`test/index.test.ts > export all > should keep checking 1`] = `
"importResolve",
"isESM",
"isSupportTypeScript",
+ "setBundleModuleLoader",
"setSnapshotModuleLoader",
]
`;
diff --git a/packages/utils/test/bundle-import.test.ts b/packages/utils/test/bundle-import.test.ts
new file mode 100644
index 0000000000..8ce53344a3
--- /dev/null
+++ b/packages/utils/test/bundle-import.test.ts
@@ -0,0 +1,90 @@
+import { strict as assert } from 'node:assert';
+import path from 'node:path';
+
+import { afterEach, describe, it } from 'vitest';
+
+import { importModule, setBundleModuleLoader } from '../src/import.ts';
+import { getFilepath } from './helper.ts';
+
+describe('test/bundle-import.test.ts', () => {
+ afterEach(() => {
+ setBundleModuleLoader(undefined);
+ });
+
+ it('returns the real module when no bundle loader is registered', async () => {
+ const result = await importModule(getFilepath('esm'));
+ assert.ok(result);
+ assert.equal(typeof result, 'object');
+ });
+
+ it('intercepts importModule with the registered loader', async () => {
+ const seen: string[] = [];
+ const fakeModule = { default: { hello: 'bundle' }, other: 'stuff' };
+ setBundleModuleLoader((p) => {
+ seen.push(p);
+ if (p.endsWith('/fixtures/esm')) return fakeModule;
+ });
+
+ const result = await importModule(getFilepath('esm'));
+ assert.deepEqual(result, fakeModule);
+ assert.ok(seen.some((p) => p.endsWith('/fixtures/esm')));
+ });
+
+ it('honors importDefaultOnly when the bundle hit has a default key', async () => {
+ setBundleModuleLoader(() => ({ default: { greet: 'hi' }, other: 'x' }));
+
+ const result = await importModule(getFilepath('esm'), { importDefaultOnly: true });
+ assert.deepEqual(result, { greet: 'hi' });
+ });
+
+ it('keeps non-default bundle hits when importDefaultOnly is enabled', async () => {
+ const fakeModule = { named: 'bundle' };
+ setBundleModuleLoader(() => fakeModule);
+
+ const result = await importModule(getFilepath('esm'), { importDefaultOnly: true });
+ assert.deepEqual(result, fakeModule);
+ });
+
+ it('keeps null bundle hits when importDefaultOnly is enabled', async () => {
+ setBundleModuleLoader(() => null);
+
+ const result = await importModule(getFilepath('esm'), { importDefaultOnly: true });
+ assert.equal(result, null);
+ });
+
+ it('unwraps __esModule double-default shape', async () => {
+ setBundleModuleLoader(() => ({
+ default: { __esModule: true, default: { fn: 'bundled' } },
+ }));
+
+ const result = await importModule(getFilepath('esm'));
+ assert.equal(result.__esModule, true);
+ assert.deepEqual(result.default, { fn: 'bundled' });
+ });
+
+ it('falls through to normal import when loader returns undefined', async () => {
+ setBundleModuleLoader(() => undefined);
+
+ const result = await importModule(getFilepath('esm'));
+ assert.ok(result);
+ assert.equal(result.default.foo, 'bar');
+ });
+
+ it('serves virtual specifiers from the loader without requiring them on disk', async () => {
+ const fakeModule = { virtual: true };
+ setBundleModuleLoader((p) => (p === 'virtual/not-on-disk' ? fakeModule : undefined));
+
+ const result = await importModule('virtual/not-on-disk');
+ assert.deepEqual(result, fakeModule);
+ });
+
+ it('normalizes Windows-style bundle paths before loader lookup', async () => {
+ const fakeModule = { windows: true };
+ const filepath = getFilepath('esm').split(path.posix.sep).join(path.win32.sep);
+
+ setBundleModuleLoader((p) => (p.endsWith('/fixtures/esm') ? fakeModule : undefined));
+
+ const result = await importModule(filepath);
+ assert.deepEqual(result, fakeModule);
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index 0a167c7226..f3161428b1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,6 +17,9 @@
{
"path": "./packages/core"
},
+ {
+ "path": "./packages/typings"
+ },
{
"path": "./packages/errors"
},