import { filename, toForwardSlashes } from '@joplin/utils/path'; import * as esbuild from 'esbuild'; import { existsSync, readFileSync } from 'fs'; import { readFile, writeFile } from 'fs/promises'; import { dirname, join, relative } from 'path'; const baseDir = dirname(__dirname); const baseNodeModules = join(baseDir, 'node_modules'); const undiciPath = join(__dirname, '../build/undici.bundle.js'); // Note: Roughly based on js-draw's use of esbuild: // https://github.com/personalizedrefrigerator/js-draw/blob/6fe6d6821402a08a8d17f15a8f48d95e5d7b084f/packages/build-tool/src/BundledFile.ts#L64 const makeBuildContext = (entryPoint: string, renderer: boolean, addDebugStats: boolean, overrides?: Partial) => { return esbuild.context({ entryPoints: [entryPoint], outfile: `${filename(entryPoint)}.bundle.js`, bundle: true, minify: true, keepNames: true, // Preserve original function names -- useful for debugging format: 'iife', // Immediately invoked function expression sourcemap: true, sourcesContent: false, // Do not embed full source file content in the .map file metafile: addDebugStats, platform: 'node', target: ['node20.0'], mainFields: renderer ? ['browser', 'main'] : ['main'], ...overrides, plugins: [ { // Configures ESBuild to require(...) certain libraries that cause issues if included directly // in the bundle. Some of these are transitive dependencies and so need to have relative paths // in the final bundle. name: 'joplin--relative-imports-for-externals', setup: build => { const externalRegex = /^(.*\.node|sqlite3|node-fetch|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom|onnxruntime-node|@xenova\/transformers)$/; build.onResolve({ filter: externalRegex }, args => { // Electron packages don't need relative requires if (args.path === 'electron' || args.path.startsWith('electron/')) { return { path: args.path, external: true, namespace: 'node' }; } // Other packages may need relative requires let path = toForwardSlashes(relative( baseDir, require.resolve(args.path, { paths: [baseNodeModules, args.resolveDir, baseDir] }), )); if (!path.startsWith('.')) { path = `./${path}`; } // Some files have .node.* extensions but are not native modules. These files are often required using // require('./something.node') rather than require('./something.node.js'). Skip path remapping for // these files: if (args.path.endsWith('.node') && (path.endsWith('.ts') || path.endsWith('.js'))) { // Normal .ts or .js file -- continue. return null; } // Log that this is external -- it should be included in "dependencies" and not "devDependencies" in package.json: console.log('External path:', path, args.importer); return { path, external: true, }; }); }, }, { // Rewrite imports to prefer .js files to .ts. Otherwise, certain files are duplicated in the final bundle name: 'joplin--prefer-js-imports', setup: build => { // Rewrite all relative imports build.onResolve({ filter: /^\./ }, args => { try { const importPath = args.path === '.' ? './index' : args.path; let path = require.resolve(importPath, { paths: [args.resolveDir, baseNodeModules, baseDir] }); // require.resolve **can** return paths with .ts extensions, presumably because // this build script is a .ts file. if (path.endsWith('.ts')) { const alternative = path.replace(/\.ts$/, '.js'); if (existsSync(alternative)) { path = alternative; } } return { path }; } catch (error) { return { errors: [{ text: `Failed to import: ${error}`, detail: error }], }; } }); }, }, { name: 'joplin--smaller-source-map-size', setup: build => { // Unless bundling with additional debug information, exclude 3rd-party // dependencies from source maps. This significantly reduces the size of the // source map, improving startup performance. // // See https://github.com/evanw/esbuild/issues/1685#issuecomment-944916409 // and https://github.com/evanw/esbuild/issues/4130 if (!addDebugStats) { const emptyMapData = Buffer.from( JSON.stringify({ version: 3, sources: [null], mappings: 'AAAA' }), 'utf-8', ).toString('base64'); const emptyMapUrl = `data:application/json;base64,${emptyMapData}`; build.onLoad({ filter: /node_modules.*js$/ }, args => { return { contents: [ readFileSync(args.path, 'utf8'), `//# sourceMappingURL=${emptyMapUrl}`, ].join('\n'), loader: 'default', }; }); } }, }, { name: 'joplin--fix-undici', setup: build => { build.onResolve({ filter: /^undici$/ }, () => { return { path: undiciPath, }; }); }, }, ...(overrides?.plugins ?? []), ], }); }; const bundleUndici = async () => { // By default, Undici is broken in Electron renderer environments and can cause renderer process // crashes when fetching certain URLs. // // Undici relies on several NodeJS global objects that are replaced in the Electron renderer process. // For example, with the Electron globals, there's no .unref() on timer handles returned by setTimeout(). // // As a workaround, add imports for many of the built-in objects so that Undici uses the NodeJS versions // of the built-ins: const undiciBuilder = await makeBuildContext( require.resolve('undici'), true, false, { outfile: undiciPath, format: 'cjs', sourcemap: false, minify: false }, ); await undiciBuilder.rebuild(); await writeFile(undiciPath, ` const { Buffer, Blob, File } = require('node:buffer'); const { TextDecoder, TextEncoder } = require('node:util'); const { URLSearchParams, URL } = require('node:url'); const { ReadableStream } = require('node:stream/web'); const { MessageChannel, MessagePort } = require('node:worker_threads'); const { setTimeout, setInterval, clearTimeout, clearInterval } = require('node:timers'); const { performance } = require('node:perf_hooks'); ${await readFile(undiciPath, 'utf-8')} `, 'utf-8'); await undiciBuilder.dispose(); }; const bundleJs = async (writeStats: boolean) => { await bundleUndici(); const entryPoints = [ { fileName: 'main.ts', renderer: false }, { fileName: 'main-html.ts', renderer: true }, ]; for (const { fileName, renderer } of entryPoints) { const compiler = await makeBuildContext(fileName, renderer, writeStats); const result = await compiler.rebuild(); if (writeStats) { const outPath = `${dirname(__dirname)}/${fileName}.meta.json`; console.log('Writing bundle stats to ', outPath); await writeFile(outPath, JSON.stringify(result.metafile, undefined, '\t')); } await compiler.dispose(); } }; export default bundleJs;