Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/lucky-places-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@mastra/deployer': patch
---

Fixed a deployer bundling regression where custom bundler externals could override safe Mastra runtime externals. This preserves internal runtime externalization and prevents ESM top-level await deadlocks from self-importing generated bundles, fixing the regression of #14860/#14863.
241 changes: 239 additions & 2 deletions packages/deployer/src/build/analyze.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { noopLogger } from '@mastra/core/logger';
import virtual from '@rollup/plugin-virtual';
import { afterEach, describe, expect, it } from 'vitest';
import { analyzeBundle } from './analyze';
import { slash } from './utils';
import { getSafeBundlerExternals } from './analyze/constants';
import { createBundler, getInputOptions } from './bundler';
import { isDependencyPartOfPackage, slash } from './utils';

const tempDirs: string[] = [];
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const tempRoot = join(packageRoot, '.tmp');

async function readGeneratedModules(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const modules = await Promise.all(
entries.map(async entry => {
const entryPath = join(dir, entry.name);
if (entry.isDirectory()) {
return readGeneratedModules(entryPath);
}
if (!entry.name.endsWith('.mjs')) {
return [];
}
return [await readFile(entryPath, 'utf-8')];
}),
);

return modules.flat();
}

afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map(dir =>
Expand Down Expand Up @@ -69,3 +90,219 @@ describe('protocol imports', () => {
expect(result.externalDependencies.has('cloudflare:workers')).toBe(false);
}, 15000);
});

describe('safe runtime externals', () => {
it('preserves user externals while keeping Mastra runtime packages externalized', () => {
expect(getSafeBundlerExternals(['pg', '@mastra/core'])).toEqual(
expect.arrayContaining(['pg', '@mastra/core', '@mastra/memory']),
);

const merged = getSafeBundlerExternals(['pg', '@mastra/core']);
expect(merged.filter(external => external === 'pg')).toHaveLength(1);
expect(merged.filter(external => external === '@mastra/core')).toHaveLength(1);
expect(merged.some(external => isDependencyPartOfPackage('@mastra/core/storage', external))).toBe(true);
});

it('keeps @mastra/core external with custom externals and top-level dynamic imports', async () => {
await mkdir(tempRoot, { recursive: true });
const tempDir = await mkdtemp(join(tempRoot, 'mastra-safe-externals-'));
tempDirs.push(tempDir);

const entryFile = join(tempDir, 'index.ts');
const outputDir = join(tempDir, '.mastra', '.build');
await mkdir(outputDir, { recursive: true });
await writeFile(
entryFile,
`
import { Mastra } from '@mastra/core/mastra';

export const storageModule = await import('@mastra/core/storage');
export const mastra = new Mastra({});
`,
);

const result = await analyzeBundle(
[entryFile],
entryFile,
{
outputDir,
projectRoot: tempDir,
platform: 'node',
bundlerOptions: {
externals: ['pg'],
enableSourcemap: false,
},
},
noopLogger,
);

expect(result.externalDependencies.has('@mastra/core')).toBe(true);
expect(result.externalDependencies.has('pg')).toBe(false);

const generatedModules = await readGeneratedModules(outputDir);
expect(generatedModules.join('\n')).not.toMatch(/import\(['"]\.\/(?:index|mastra)\.mjs['"]\)/);
}, 15000);

it('does not rewrite internal Mastra dynamic imports to self-imports in final output', async () => {
await mkdir(tempRoot, { recursive: true });
const tempDir = await mkdtemp(join(tempRoot, 'mastra-no-self-imports-'));
tempDirs.push(tempDir);

const entryFile = join(tempDir, 'mastra.ts');
const outputDir = join(tempDir, '.mastra', '.build');
const bundleDir = join(tempDir, '.mastra', 'output');
await mkdir(outputDir, { recursive: true });
await mkdir(bundleDir, { recursive: true });
await writeFile(
entryFile,
`
import { Mastra } from '@mastra/core/mastra';

export const mastra = new Mastra({});
`,
);

const analyzedBundleInfo = await analyzeBundle(
[
`
import { mastra } from '#mastra';

export { mastra };
export const storageModule = await import('@mastra/core/storage');
`,
],
entryFile,
{
outputDir,
projectRoot: tempDir,
platform: 'node',
bundlerOptions: {
externals: ['pg'],
enableSourcemap: false,
},
},
noopLogger,
);

const inputOptions = await getInputOptions(
entryFile,
analyzedBundleInfo,
'node',
{ 'process.env.NODE_ENV': JSON.stringify('production') },
{
projectRoot: tempDir,
enableEsmShim: true,
externalsPreset: false,
},
);

inputOptions.input = { index: '#entry' };
inputOptions.plugins = [
virtual({
'#entry': `
import { mastra } from '#mastra';

export { mastra };
export const storageModule = await import('@mastra/core/storage');
`,
}),
...(Array.isArray(inputOptions.plugins) ? inputOptions.plugins : []),
];

const bundler = await createBundler(inputOptions, {
dir: bundleDir,
manualChunks: {
mastra: ['#mastra'],
},
});

await bundler.write();
await bundler.close();

const generatedModules = await readGeneratedModules(bundleDir);
const generatedOutput = generatedModules.join('\n');
expect(generatedOutput).toContain("import('@mastra/core/storage')");
expect(generatedOutput).not.toMatch(/import\(['"]\.\/(?:index|mastra)\.mjs['"]\)/);
}, 15000);

it('preserves Mastra subpath dynamic imports inside the TLA mastra chunk', async () => {
await mkdir(tempRoot, { recursive: true });
const tempDir = await mkdtemp(join(tempRoot, 'mastra-subpath-tla-'));
tempDirs.push(tempDir);

const entryFile = join(tempDir, 'mastra.ts');
const outputDir = join(tempDir, '.mastra', '.build');
const bundleDir = join(tempDir, '.mastra', 'output');
await mkdir(outputDir, { recursive: true });
await mkdir(bundleDir, { recursive: true });
await writeFile(
entryFile,
`
import { Mastra } from '@mastra/core/mastra';

export const storageModule = await import('@mastra/core/storage');
export const mastra = new Mastra({});
`,
);

const serverEntry = `
import { mastra } from '#mastra';

export { mastra };
`;

const analyzedBundleInfo = await analyzeBundle(
[serverEntry],
entryFile,
{
outputDir,
projectRoot: tempDir,
platform: 'node',
bundlerOptions: {
externals: ['pg'],
enableSourcemap: false,
},
},
noopLogger,
);

const inputOptions = await getInputOptions(
entryFile,
analyzedBundleInfo,
'node',
{ 'process.env.NODE_ENV': JSON.stringify('production') },
{
projectRoot: tempDir,
enableEsmShim: true,
externalsPreset: false,
},
);

inputOptions.input = { index: '#entry' };
inputOptions.plugins = [
virtual({
'#entry': serverEntry,
}),
...(Array.isArray(inputOptions.plugins) ? inputOptions.plugins : []),
];

const bundler = await createBundler(inputOptions, {
dir: bundleDir,
manualChunks: {
mastra: ['#mastra'],
},
});

const { output } = await bundler.write();
await bundler.close();

const generatedModules = await readGeneratedModules(bundleDir);
const generatedOutput = generatedModules.join('\n');
expect(generatedOutput).toContain("import('@mastra/core/storage')");
expect(generatedOutput).not.toMatch(/import\(['"]\.\/[^'"]+\.mjs['"]\)/);

const mastraChunk = output.find(chunk => chunk.type === 'chunk' && chunk.fileName === 'mastra.mjs');
expect(mastraChunk).toBeDefined();
expect(mastraChunk?.type === 'chunk' ? mastraChunk.imports : []).not.toContain('index.mjs');
}, 15000);
});
6 changes: 3 additions & 3 deletions packages/deployer/src/build/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { WorkspacePackageInfo } from '../bundler/workspaceDependencies';
import { validate, ValidationError } from '../validator/validate';
import { analyzeEntry } from './analyze/analyzeEntry';
import { bundleExternals } from './analyze/bundleExternals';
import { DEPS_TO_IGNORE, GLOBAL_EXTERNALS } from './analyze/constants';
import { DEPS_TO_IGNORE, GLOBAL_EXTERNALS, getSafeBundlerExternals } from './analyze/constants';
import { checkConfigExport } from './babel/check-config-export';
import { detectPinoTransports } from './babel/detect-pino-transports';
import type { BundlerOptions, DependencyMetadata, ExternalDependencyInfo } from './types';
Expand Down Expand Up @@ -353,7 +353,7 @@ export async function analyzeBundle(

let index = 0;
const depsToOptimize = new Map<string, DependencyMetadata>();
const allExternals: string[] = [...GLOBAL_EXTERNALS, ...userExternals].filter(Boolean) as string[];
const allExternals = getSafeBundlerExternals(userExternals);

// Collect pino transports detected across all entries
const detectedPinoTransports = new Set<string>();
Expand Down Expand Up @@ -431,7 +431,7 @@ export async function analyzeBundle(
const { output, fileNameToDependencyMap, usedExternals } = await bundleExternals(depsToOptimize, outputDir, {
bundlerOptions: {
...bundlerOptions,
externals: bundlerOptions?.externals ?? allExternals,
externals: bundlerOptions?.externals === true ? true : allExternals,
isDev,
},
projectRoot,
Expand Down
4 changes: 2 additions & 2 deletions packages/deployer/src/build/analyze/bundleExternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
slash,
} from '../utils';
import type { BundlerPlatform } from '../utils';
import { DEPS_TO_IGNORE, GLOBAL_EXTERNALS, DEPRECATED_EXTERNALS } from './constants';
import { DEPS_TO_IGNORE, getSafeBundlerExternals } from './constants';

type VirtualDependency = {
name: string;
Expand Down Expand Up @@ -484,7 +484,7 @@ export async function bundleExternals(

// If `externals` is an array (and not `true`), we proceed as normal
const externalsList = Array.isArray(customExternals) ? customExternals : [];
const allExternals = [...GLOBAL_EXTERNALS, ...DEPRECATED_EXTERNALS, ...externalsList];
const allExternals = getSafeBundlerExternals(externalsList, { includeDeprecated: true });

const workspacePackagesNames = Array.from(workspaceMap.keys());
const packagesToTranspile = new Set([...transpilePackages, ...workspacePackagesNames]);
Expand Down
27 changes: 27 additions & 0 deletions packages/deployer/src/build/analyze/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
export const DEPS_TO_IGNORE = ['#tools', 'execa'];

// Keep Mastra runtime packages external to avoid reintroducing the ESM TLA
// circular chunk deadlocks fixed in #14860/#14863.
export const MASTRA_RUNTIME_EXTERNALS = [
'@mastra/core',
'@mastra/dsql',
'@mastra/libsql',
'@mastra/memory',
'@mastra/mssql',
'@mastra/pg',
];

export const GLOBAL_EXTERNALS = [
'pino',
'pino-pretty',
Expand All @@ -15,3 +26,19 @@ export const GLOBAL_EXTERNALS = [
'execa',
];
export const DEPRECATED_EXTERNALS = ['fastembed', 'nodemailer', 'jsdom', 'sqlite3'];

export function mergeBundlerExternals(...externalLists: (readonly string[] | undefined)[]) {
return Array.from(new Set(externalLists.flatMap(externals => externals ?? []).filter(Boolean)));
}

export function getSafeBundlerExternals(
userExternals: readonly string[] = [],
{ includeDeprecated = false }: { includeDeprecated?: boolean } = {},
) {
return mergeBundlerExternals(
GLOBAL_EXTERNALS,
includeDeprecated ? DEPRECATED_EXTERNALS : undefined,
MASTRA_RUNTIME_EXTERNALS,
userExternals,
);
}