The Build Pipeline: From Source to npm Package
Prerequisites
- ›Articles 1-3: Full understanding of the architecture, language definitions, and worker system
- ›Familiarity with Rollup configuration and plugin authoring
- ›Understanding of Vite library mode
- ›Knowledge of ESM vs AMD module formats
The Build Pipeline: From Source to npm Package
Monaco Editor ships two module formats: a modern ESM bundle built with Rollup (tree-shakable, the recommended path) and a legacy AMD bundle built with Vite (deprecated but maintained for backward compatibility). Each format requires distinct build strategies, and the pipeline includes several non-obvious steps: path remapping from source directories to the published package structure, a custom Rollup plugin for worker code splitting, TypeScript compiler vendoring with ESM patches, and metadata generation for the webpack plugin.
This article traces the complete pipeline from npm run build to the out/monaco-editor/ directory that becomes the published npm package.
Build Orchestration
The entry point is build-monaco-editor.ts, which sequences all build steps:
build/build-monaco-editor.ts#L14-L56
async function run() {
await rm(path.join(REPO_ROOT, './out/monaco-editor'), { recursive: true, force: true });
await buildESM();
await buildAmdMinDev();
// copy and patch types.d.ts
(() => {
let contents = fs.readFileSync('build/amd/out/types.d.ts', { encoding: 'utf8' });
contents += '\n\ndeclare global { export import monaco = editor_main; }\n';
fs.writeFileSync('out/monaco-editor/monaco.d.ts', contents);
})();
createThirdPartyNoticesDotTxt();
generateEsmMetadataJsAndDTs();
// package.json — remove private flag and postinstall script
(() => {
const packageJSON = readFiles('package.json', { base: '' })[0];
const json = JSON.parse(packageJSON.contents.toString());
json.private = false;
delete json.scripts['postinstall'];
// ...
})();
}
flowchart TD
START[npm run build-monaco-editor] --> CLEAN[Clean out/monaco-editor/]
CLEAN --> ESM[Build ESM<br/>Rollup]
ESM --> AMD[Build AMD<br/>Vite dev + min]
AMD --> TYPES[Patch type declarations<br/>Append global monaco namespace]
TYPES --> TPN[Generate ThirdPartyNotices.txt]
TPN --> META[Generate metadata.js/d.ts<br/>for webpack plugin]
META --> PKG[Assemble package.json<br/>Remove private flag]
PKG --> FILES[Copy README, CHANGELOG, LICENSE]
The order matters. The ESM build runs first because the AMD build can reuse some of its output. The metadata generation runs after both builds because it scans the built output to discover which features and languages exist.
Entry Point Resolution and Path Mapping
The shared.mjs file provides the critical logic that both build configurations depend on: discovering entry points and remapping source paths to output paths.
export function getEntryPoints(includeFeatures = false, includeDeprecated = true) {
const features = includeFeatures
? Object.fromEntries(
findFiles('./src/**/register.*')
.filter(p => !p.includes('.d.ts'))
.map(v => [v, join(root, v)])
)
: {};
const deprecatedEntryPoints = /* ... discover deprecated paths ... */;
return {
...features,
'editor': join(root, 'src/editor.ts'),
'index': join(root, './src/index.ts'),
...deprecatedEntryPoints,
};
}
The path mapping translates source locations to the published package structure:
const mappedPaths = {
[join(root, 'node_modules/monaco-editor-core/esm/')]: '.',
[join(root, 'node_modules/')]: 'external/',
[join(root, 'monaco-lsp-client/')]: 'external/monaco-lsp-client/',
[join(root, 'src/deprecated')]: 'vs/',
[join(root, 'src/')]: 'vs/',
};
export function mapModuleId(moduleId, newExt) {
for (const [key, val] of Object.entries(mappedPaths)) {
if (moduleId.startsWith(key)) {
const relativePath = moduleId.substring(key.length);
return changeExt(join(val, relativePath), newExt);
}
}
return undefined;
}
This mapping is the reason import 'monaco-editor' resolves to files under esm/vs/ in the published package:
| Source Path | Published Path |
|---|---|
node_modules/monaco-editor-core/esm/vs/editor/... |
esm/vs/editor/... (root) |
src/editor.ts |
esm/vs/editor.js |
src/languages/definitions/typescript/... |
esm/vs/languages/definitions/typescript/... |
src/deprecated/editor/editor.main.ts |
esm/vs/editor/editor.main.js |
node_modules/vscode-css-languageservice/... |
esm/external/vscode-css-languageservice/... |
Tip: If you're ever confused about why Monaco imports use paths like
monaco-editor/esm/vs/..., this mapping is the answer. Thevs/prefix comes from thesrc/ → vs/mapping and themonaco-editor-core/esm/ → .mapping, which means core files land at the root of theesm/directory.
ESM Build with Rollup
The Rollup configuration is the heart of the modern build:
build/esm/rollup.config.mjs#L23-L89
export default defineConfig({
input: {
...getEntryPoints(true),
},
output: {
dir: outDir,
format: 'es',
entryFileNames: function (chunkInfo) {
const moduleId = chunkInfo.facadeModuleId;
if (moduleId) {
const r = mapModuleId(moduleId, '.js');
if (r !== undefined) return r;
}
return '[name].js';
},
preserveModules: true,
hoistTransitiveImports: false,
},
plugins: [
del({ targets: outDir, force: true }),
/* emit-additional-files plugin */,
urlToEsmPlugin(),
esbuild(),
keepCssImports({ /* ... */ }),
nodeResolve({ dedupe: ['monaco-editor-core', '@vscode/monaco-lsp-client'], browser: true }),
],
});
Two critical settings: preserveModules: true outputs one file per input module instead of bundling them together. This is essential for tree-shaking — consumers' bundlers can analyze and eliminate unused modules. hoistTransitiveImports: false prevents Rollup from moving transitive imports into entry chunks, keeping the module graph clean.
The urlToEsmPlugin is a custom Rollup plugin that handles worker code splitting:
build/esm/rollup-url-to-module-plugin/index.mjs#L9-L71
export function urlToEsmPlugin() {
return {
name: 'import-meta-url',
async transform(code, id) {
const regex = /new\s+URL\s*\(\s*(['"`])(.*?)\1\s*,\s*import\.meta\.url\s*\)?/g;
// ...
while ((match = regex.exec(code)) !== null) {
let path = match[2];
if (!path.endsWith('?esm')) continue;
path = path.slice(0, -4); // remove ?esm suffix
const resolved = await this.resolve(path, id);
const refId = this.emitFile({ type: 'chunk', id: resolved.id });
const replacement = `import.meta.ROLLUP_FILE_URL_OBJ_${refId}`;
// replace the URL expression
}
}
};
}
flowchart LR
SRC["new URL('./ts.worker?esm',<br/>import.meta.url)"] --> |urlToEsmPlugin| EMIT[Rollup emitFile<br/>type: 'chunk']
EMIT --> CHUNK[ts.worker.js<br/>separate chunk]
SRC --> |transform| OUT["import.meta.ROLLUP_FILE_URL_OBJ_xxx"]
OUT --> |Rollup resolves| URL["new URL('./ts.worker.js',<br/>import.meta.url)"]
The ?esm suffix is a convention used throughout the codebase (recall from Article 3: new URL('./ts.worker?esm', import.meta.url)). It signals to the plugin that this URL should be emitted as a separate chunk. Without the ?esm suffix, the URL is left untouched.
AMD Build with Vite (Legacy)
The AMD build uses Vite in library mode:
build/amd/vite.config.mjs#L36-L82
export default defineConfig(async (args) => {
return {
build: {
lib: {
entry: {
...getEntryPoints(),
'nls.messages-loader': resolve(__dirname, 'src/nls.messages-loader.js'),
'src/deprecated/editor/editor.main.ts': resolve(__dirname, 'src/editor.main.ts'),
},
formats: ['amd']
},
outDir: resolve(__dirname, '../../out/monaco-editor/',
args.mode === 'development' ? 'dev' : 'min', 'vs'),
minify: args.mode !== 'development',
emptyOutDir: true
},
};
});
The AMD build produces two outputs: dev/vs/ (unminified, for development) and min/vs/ (minified, for production). Both use the AMD module format for compatibility with RequireJS-based applications.
A notable hack is the fix-amd-require plugin (lines 88-107) that patches require.toUrl() references. Vite's Rollup-based build doesn't correctly handle AMD's require dependency, so the plugin post-processes chunks to inject it.
TypeScript Compiler Vendoring
The importTypescript.ts script is one of the more unusual parts of the build. It takes the TypeScript compiler from node_modules and patches it for browser use:
build/importTypescript.ts#L38-L77
let tsServices = fs.readFileSync(path.join(TYPESCRIPT_LIB_SOURCE, 'typescript.js')).toString();
// ESM compatibility: neutralize Node.js require/module
tsServices = `
/* MONACOCHANGE */
var require = undefined;
var module = { exports: {} };
/* END MONACOCHANGE */
` + tsServices;
// Re-export key symbols for ESM consumers
const tsServices_esm = generatedNote + tsServices + `
// MONACOCHANGE
export var createClassifier = ts.createClassifier;
export var createLanguageService = ts.createLanguageService;
export var displayPartsToString = ts.displayPartsToString;
// ... more exports
// END MONACOCHANGE
`;
The var require = undefined trick is key. The TypeScript compiler's source code contains typeof require !== 'undefined' checks to detect Node.js. By setting require to undefined, these checks fail gracefully, and the compiler runs in browser mode. The module = { exports: {} } prevents similar issues with CommonJS detection.
The script also generates lib.ts — a file containing all TypeScript lib declaration files (like lib.es2015.d.ts, lib.dom.d.ts) as string literals:
build/importTypescript.ts#L91-L124
function importLibs() {
let strLibResult = `export const libFileMap: Record<string, string> = {}\n`;
const dtsFiles = fs.readdirSync(TYPESCRIPT_LIB_SOURCE).filter((f) => f.includes('lib.'));
while (dtsFiles.length > 0) {
const name = dtsFiles.shift();
const output = readLibFile(name).replace(/\r\n/g, '\n');
strLibResult += `libFileMap['${name}'] = "${escapeText(output)}";\n`;
}
fs.writeFileSync(path.join(TYPESCRIPT_LIB_DESTINATION, 'lib.ts'), strLibResult);
}
This is how the TypeScript worker (as we saw in Article 3) can resolve lib.d.ts files without a filesystem — they're compiled into the bundle as string constants.
flowchart TD
TSSRC[node_modules/typescript/lib/typescript.js] --> PATCH[Patch for ESM<br/>var require = undefined]
PATCH --> EXPORT[Add named exports<br/>createLanguageService, etc.]
EXPORT --> OUT1[typescriptServices.js]
LIBS[node_modules/typescript/lib/lib.*.d.ts] --> ENCODE[Read & escape as strings]
ENCODE --> OUT2[lib.ts<br/>libFileMap record]
VERSION[npm ls typescript] --> OUT3[typescriptServicesMetadata.ts<br/>version string]
Metadata Generation for the Webpack Plugin
After both builds complete, releaseMetadata.ts scans the output to produce metadata.js:
build/releaseMetadata.ts#L12-L81
function getBasicLanguages(): { label: string; entry: string }[] {
const files = glob.sync('./out/monaco-editor/esm/vs/languages/definitions/*/register.js');
return files.map((file) => {
const label = file.substring(/* ... */).replace('/register.js', '');
return { label, entry: `vs/languages/definitions/${label}/register` };
});
}
function getAdvancedLanguages() {
// ... discovers languages with workers (TS, CSS, HTML, JSON)
return result.push({
label: lang,
entry: entry,
worker: { id: workerId, entry: workerEntry }
});
}
The output is a JavaScript module listing every feature and language with its entry point and optional worker:
build/releaseMetadata.ts#L158-L164
const jsContents = `
exports.features = ${JSON.stringify(features, null, ' ')};
exports.languages = ${JSON.stringify(languages, null, ' ')};
`;
This metadata.js file is the contract between the Monaco npm package and the webpack plugin. The plugin reads it to discover what features exist, what languages are available, and which workers need to be created as separate webpack entry points. We'll explore this integration in the next article.
Tip: If you add a new language or feature to Monaco and the webpack plugin doesn't pick it up, the issue is almost certainly in metadata generation. The glob patterns in
releaseMetadata.tsmust match your new file's path structure.
What's Next
The build pipeline produces a carefully structured npm package, but getting that package to work correctly in a web application is its own challenge — especially the worker files. In the next article, we'll examine the webpack plugin that automates Monaco integration, and compare strategies across webpack, Vite, and esbuild.