Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
112 changes: 112 additions & 0 deletions tools/egg-bundler/src/lib/PackRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export interface PackRunnerResult {
readonly files: readonly string[];
}

interface PackageJson {
readonly exports?: unknown;
}

// SWC decorator compilation picks up tsconfig from the OUTPUT dir, not the
// project. Without this, tegg decorator metadata silently drops. (T0 blocker.)
const OUTPUT_TSCONFIG = {
Expand All @@ -40,6 +44,15 @@ const OUTPUT_PACKAGE_JSON = { type: 'commonjs' };

const require = createRequire(import.meta.url);

// @utoo/pack 1.4.1 resolves this package's browser/default export even for
// node builds, which drops the named createSupportsColor export.
const NODE_CONDITION_ALIAS_DEPS = [
{
issuerPackage: 'supports-hyperlinks',
dependency: 'supports-color',
},
] as const;

// Use CJS entry explicitly: under pnpm workspace links the ESM build's
// extensionless relative imports fail to resolve.
const DEFAULT_BUILD_FUNC: BuildFunc = async (wrapped, projectPath, rootPath) => {
Expand Down Expand Up @@ -80,6 +93,8 @@ export class PackRunner {
umdExternals[k] = { commonjs: v, root: v };
}

const nodeConditionAliases = await this.#resolveNodeConditionAliases(projectPath);

const config = {
entry: entries.map((e) => ({ name: e.name, import: e.filepath })),
target: 'node 22',
Expand All @@ -90,6 +105,7 @@ export class PackRunner {
type: 'standalone',
},
externals: umdExternals,
...(Object.keys(nodeConditionAliases).length > 0 ? { resolve: { alias: nodeConditionAliases } } : {}),
optimization: {
treeShaking: false,
minify: false,
Expand All @@ -108,6 +124,102 @@ export class PackRunner {
return { outputDir, files };
}

async #resolveNodeConditionAliases(projectPath: string): Promise<Record<string, string>> {
const aliases: Record<string, string> = {};

for (const { issuerPackage, dependency } of NODE_CONDITION_ALIAS_DEPS) {
const issuerDir = await this.#findPackageDir(issuerPackage, projectPath);
if (!issuerDir) continue;

const dependencyDir = await this.#findPackageDir(dependency, issuerDir);
if (!dependencyDir) continue;

const pkg = await this.#readPackageJson(dependencyDir);
const nodeEntry = this.#resolveExportsNodeEntry(pkg.exports);
if (!nodeEntry) continue;

aliases[dependency] = this.#resolvePackageEntryPath(dependencyDir, nodeEntry);
}

return aliases;
}

async #findPackageDir(name: string, fromDir: string): Promise<string | undefined> {
const nameParts = name.split('/');
let dir = fromDir;
while (true) {
const candidate = path.join(dir, 'node_modules', ...nameParts);
try {
const stat = await fs.stat(candidate);
if (stat.isDirectory()) return candidate;
} catch {
// continue walking upward
}

const parent = path.dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
}
Comment thread
killagu marked this conversation as resolved.
Outdated

async #readPackageJson(packageDir: string): Promise<PackageJson> {
const packageJsonPath = path.join(packageDir, 'package.json');
try {
return JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) as PackageJson;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw new Error(`[@eggjs/egg-bundler] failed to read ${packageJsonPath}`, { cause: error });
}
}

#resolveExportsNodeEntry(exportsField: unknown): string | undefined {
if (typeof exportsField === 'string') return exportsField;
if (!exportsField || typeof exportsField !== 'object' || Array.isArray(exportsField)) return undefined;

const map = exportsField as Record<string, unknown>;
const keys = Object.keys(map);
const rootTarget = keys.length > 0 && !keys.some((key) => key.startsWith('.')) ? map : map['.'];
return this.#resolvePackageTarget(rootTarget);
}

#resolvePackageTarget(target: unknown, inNodeCondition = false): string | undefined {
if (typeof target === 'string') return target;
if (Array.isArray(target)) {
for (const item of target) {
const resolved = this.#resolvePackageTarget(item, inNodeCondition);
if (resolved) return resolved;
}
return undefined;
}
if (!target || typeof target !== 'object') return undefined;

const map = target as Record<string, unknown>;
if (Object.hasOwn(map, 'node')) return this.#resolvePackageTarget(map.node, true);

if (inNodeCondition) {
for (const condition of ['import', 'default', 'require'] as const) {
const resolved = this.#resolvePackageTarget(map[condition], true);
if (resolved) return resolved;
}
}

return undefined;
}

#resolvePackageEntryPath(packageDir: string, entry: string): string {
if (path.isAbsolute(entry) || /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(entry)) {
throw new Error(`[@eggjs/egg-bundler] package export entry must be relative: ${entry}`);
}

const resolved = path.resolve(packageDir, entry);
const rel = path.relative(packageDir, resolved);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`[@eggjs/egg-bundler] package export entry escapes package root: ${entry}`);
}

return resolved;
}

async #collectFiles(dir: string): Promise<readonly string[]> {
const files: string[] = [];
await this.#collectFilesInDir(dir, dir, files);
Expand Down
67 changes: 67 additions & 0 deletions tools/egg-bundler/test/PackRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,77 @@ describe('PackRunner', () => {
expect(config.externals).toEqual({
'@eggjs/core': { commonjs: '@eggjs/core', root: '@eggjs/core' },
});
expect(config.resolve).toBeUndefined();
expect(projectPath).toBe(tmpDir);
expect(rootPath).toBe(tmpDir);
});

it('aliases supports-color to its node export when bundled through supports-hyperlinks', async () => {
const buildFunc = vi.fn<BuildFunc>(async () => {});
const supportsHyperlinksDir = path.join(tmpDir, 'node_modules', 'supports-hyperlinks');
const supportsColorDir = path.join(supportsHyperlinksDir, 'node_modules', 'supports-color');
await fs.mkdir(supportsColorDir, { recursive: true });
await fs.writeFile(
path.join(supportsHyperlinksDir, 'package.json'),
JSON.stringify({ name: 'supports-hyperlinks' }),
);
await fs.writeFile(
path.join(supportsColorDir, 'package.json'),
JSON.stringify({
name: 'supports-color',
exports: {
types: './index.d.ts',
node: './index.js',
default: './browser.js',
},
}),
);

await makeRunner({ buildFunc }).run();

const config = (buildFunc.mock.calls[0]![0] as { config: Record<string, unknown> }).config;
expect(config.resolve).toEqual({
alias: {
'supports-color': path.join(supportsColorDir, 'index.js'),
},
});
});

it('finds hoisted supports-color and resolves nested node condition exports', async () => {
const buildFunc = vi.fn<BuildFunc>(async () => {});
const supportsHyperlinksDir = path.join(tmpDir, 'node_modules', 'supports-hyperlinks');
const supportsColorDir = path.join(tmpDir, 'node_modules', 'supports-color');
await fs.mkdir(supportsColorDir, { recursive: true });
await fs.mkdir(supportsHyperlinksDir, { recursive: true });
await fs.writeFile(
path.join(supportsHyperlinksDir, 'package.json'),
JSON.stringify({ name: 'supports-hyperlinks' }),
);
await fs.writeFile(
path.join(supportsColorDir, 'package.json'),
JSON.stringify({
name: 'supports-color',
exports: {
types: './index.d.ts',
node: {
import: './index.js',
default: './browser.js',
},
default: './browser.js',
},
}),
);

await makeRunner({ buildFunc }).run();

const config = (buildFunc.mock.calls[0]![0] as { config: Record<string, unknown> }).config;
expect(config.resolve).toEqual({
alias: {
'supports-color': path.join(supportsColorDir, 'index.js'),
},
});
});

it('disables treeShaking and minify in the pack config (tegg runtime requires the full graph)', async () => {
const buildFunc = vi.fn<BuildFunc>(async () => {});
await makeRunner({ buildFunc }).run();
Expand Down
Loading