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" },