Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="@eggjs/typings" />
49 changes: 49 additions & 0 deletions packages/typings/package.json
Original file line number Diff line number Diff line change
@@ -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 <fengmk2@gmail.com>",
"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"
}
}
11 changes: 11 additions & 0 deletions packages/typings/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
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.

medium

The BundleModuleLoader currently only receives the raw filepath. In multi-application environments, it is important to verify that the global bundle store's baseDir matches the requested baseDir to prevent using an incorrect manifest. Consider passing options (including baseDir) to the loader to facilitate this check.

Suggested change
export type BundleModuleLoader = (filepath: string) => unknown;
export type BundleModuleLoader = (filepath: string, options?: { baseDir?: string; [key: string]: any }) => unknown;
References
  1. When using a global bundle store, verify that its baseDir matches the requested baseDir to prevent using an incorrect manifest in multi-application environments.


declare global {
var __EGG_BUNDLE_MODULE_LOADER__: BundleModuleLoader | undefined;
}
3 changes: 3 additions & 0 deletions packages/typings/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
7 changes: 7 additions & 0 deletions packages/typings/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'tsdown';

export default defineConfig({
entry: {
index: 'src/index.ts',
},
});
4 changes: 3 additions & 1 deletion packages/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
"scripts": {
"typecheck": "tsgo --noEmit"
},
"dependencies": {},
"dependencies": {
"@eggjs/typings": "workspace:*"
},
"devDependencies": {
"coffee": "catalog:",
"mm": "catalog:",
Expand Down
27 changes: 27 additions & 0 deletions packages/utils/src/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<any> {
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;
}
}
Comment on lines +412 to +423
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.

medium

The logic for unwrapping __esModule "double default" and handling importDefaultOnly is duplicated multiple times. Introducing a private helper function improves maintainability and ensures consistency. Additionally, passing options to the bundle loader allows it to verify the baseDir against the global bundle store, ensuring the correct manifest is used in multi-application environments.

function unwrapModule(obj: any, options?: ImportModuleOptions): 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;
}

export async function importModule(filepath: string, options?: ImportModuleOptions): Promise<any> {
  const _bundleModuleLoader = globalThis.__EGG_BUNDLE_MODULE_LOADER__;
  if (_bundleModuleLoader) {
    const hit = _bundleModuleLoader(normalizeBundleModulePath(filepath), options);
    if (hit !== undefined) {
      return unwrapModule(hit, options);
    }
  }
References
  1. When using a global bundle store, verify that its baseDir matches the requested baseDir to prevent using an incorrect manifest in multi-application environments.


const moduleFilePath = importResolve(filepath, options);

if (_snapshotModuleLoader) {
Expand Down
1 change: 1 addition & 0 deletions packages/utils/test/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ exports[`test/index.test.ts > export all > should keep checking 1`] = `
"importResolve",
"isESM",
"isSupportTypeScript",
"setBundleModuleLoader",
"setSnapshotModuleLoader",
]
`;
90 changes: 90 additions & 0 deletions packages/utils/test/bundle-import.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 3 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
{
"path": "./packages/core"
},
{
"path": "./packages/typings"
},
{
"path": "./packages/errors"
},
Expand Down
Loading