mirror of
https://github.com/zadam/trilium.git
synced 2026-09-13 18:46:08 +05:00
The main process now builds as main.mjs with dynamic-import boundaries split into chunks/ that only load on first use; dist/package.json points Electron at the new entry. The preload stays CJS (Electron's sandboxed renderer cannot load ESM preloads) and image_worker.cjs stays CJS (spawned by its path). The ESM banner now resolves __dirname in a chunk to the bundle root rather than chunks/: bundled code uses __dirname to find siblings of the entry (preload.cjs, image_worker.cjs, getResourceDir), and which chunk a module lands in must not change what the path means. Verified by launching the built app: the renderer loads over trilium-app:// and reaches the backend through the preload bridge. Main-process RSS on identical boots: 348 MB (CJS) -> 287 MB (ESM). The "Error executing query" lines at boot predate this change (identical under CJS). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
270 lines
13 KiB
TypeScript
270 lines
13 KiB
TypeScript
import { execSync } from "child_process";
|
|
import { build as esbuild } from "esbuild";
|
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
import { delimiter, join } from "path";
|
|
|
|
export default class BuildHelper {
|
|
|
|
rootDir: string;
|
|
projectDir: string;
|
|
outDir: string;
|
|
|
|
constructor(projectPath: string) {
|
|
this.rootDir = join(__dirname, "..");
|
|
this.projectDir = join(this.rootDir, projectPath);
|
|
this.outDir = join(this.projectDir, "dist");
|
|
|
|
rmSync(this.outDir, { recursive: true, force: true });
|
|
mkdirSync(this.outDir, { recursive: true });
|
|
}
|
|
|
|
copy(projectDirPath: string, outDirPath: string) {
|
|
let sourcePath: string;
|
|
if (projectDirPath.startsWith("/") || projectDirPath.startsWith("\\")) {
|
|
sourcePath = join(this.rootDir, projectDirPath.substring(1));
|
|
} else {
|
|
sourcePath = join(this.projectDir, projectDirPath);
|
|
}
|
|
|
|
if (outDirPath.endsWith("/")) {
|
|
mkdirSync(join(this.outDir, outDirPath), { recursive: true });
|
|
}
|
|
cpSync(sourcePath, join(this.outDir, outDirPath), { recursive: true, dereference: true });
|
|
}
|
|
|
|
deleteFromOutput(path: string) {
|
|
rmSync(join(this.outDir, path), { recursive: true });
|
|
}
|
|
|
|
/**
|
|
* @param entryPoints source entry points to bundle.
|
|
* @param opts.importMetaUrlShim redirects `import.meta.url` to the bundle's own file so
|
|
* bundled ESM deps calling `createRequire(import.meta.url)` work in CJS output (defaults
|
|
* to `true`). Must be disabled for scripts that run in Electron's sandboxed renderer (e.g.
|
|
* the desktop preload), where the injected `require("node:url")` banner throws. Ignored
|
|
* for ESM output, where `import.meta.url` needs no shim.
|
|
* @param opts.format output format (defaults to `"cjs"`). `"esm"` emits `.mjs` with code
|
|
* splitting: dynamic `import()` boundaries become separate chunks under `chunks/`, so
|
|
* V8 never parses or retains the source of a subsystem until it is first used. The
|
|
* desktop preload must stay `"cjs"` — Electron's sandboxed renderer cannot load ESM.
|
|
*/
|
|
async buildBackend(entryPoints: string[], opts: { importMetaUrlShim?: boolean; format?: "cjs" | "esm" } = {}) {
|
|
const { importMetaUrlShim = true, format = "cjs" } = opts;
|
|
const esm = format === "esm";
|
|
const result = await esbuild({
|
|
entryPoints: entryPoints.map(e => join(this.projectDir, e)),
|
|
tsconfig: join(this.projectDir, "tsconfig.app.json"),
|
|
platform: "node",
|
|
bundle: true,
|
|
outdir: this.outDir,
|
|
outExtension: {
|
|
".js": esm ? ".mjs" : ".cjs"
|
|
},
|
|
format,
|
|
splitting: esm,
|
|
chunkNames: "chunks/[name]-[hash]",
|
|
external: [
|
|
"electron",
|
|
"better-sqlite3",
|
|
"pdfjs-dist",
|
|
"./xhr-sync-worker.js",
|
|
"vite",
|
|
"tesseract.js",
|
|
// Test fixtures referenced via require.resolve from
|
|
// integration-test-only code paths in apps/server. These
|
|
// paths are gated at runtime by TRILIUM_INTEGRATION_TEST and
|
|
// never reached in production, but esbuild can't see through
|
|
// the gate during static analysis. Marking them external
|
|
// suppresses the spurious "require.resolve not external"
|
|
// warning without affecting the bundle behavior.
|
|
"@triliumnext/core/src/test/*",
|
|
// schema.sql is read via core_assets.ts, which prefers a
|
|
// bundled copy at RESOURCE_DIR/schema.sql (placed there by
|
|
// apps/server/scripts/build.ts) and only falls back to
|
|
// require.resolve in dev/test mode. In bundled production
|
|
// the require.resolve branch is unreachable, but esbuild
|
|
// still sees the static string and warns. External marker
|
|
// suppresses the warning without changing runtime behavior.
|
|
"@triliumnext/core/src/assets/*"
|
|
],
|
|
metafile: true,
|
|
loader: {
|
|
".css": "text",
|
|
".ejs": "text"
|
|
},
|
|
define: {
|
|
"process.env.NODE_ENV": JSON.stringify("production"),
|
|
// CJS output has no `import.meta`, so esbuild rewrites
|
|
// `import.meta.url` to `{}` (→ undefined). Bundled ESM deps
|
|
// (e.g. @anthropic-ai/claude-agent-sdk) call
|
|
// `createRequire(import.meta.url)` at module top level, which
|
|
// then throws `ERR_INVALID_ARG_VALUE`. Redirect it to the
|
|
// bundle's own file so createRequire()/`.resolve()` anchor at
|
|
// dist/ and can still locate sibling node_modules packages.
|
|
...(importMetaUrlShim && !esm && { "import.meta.url": "__bundleImportMetaUrl" }),
|
|
},
|
|
// The CJS banner defines the redirect target above. It uses
|
|
// `require("node:url")`, which throws in Electron's sandboxed
|
|
// renderer, so it must be omitted for preload-style bundles.
|
|
// The ESM banner is the mirror image: bundled CJS deps reference
|
|
// `require`, `__filename` and `__dirname`, which do not exist in
|
|
// ESM, so each output file defines them from `import.meta.url`.
|
|
// esbuild's `__require` interop helper picks up the banner's
|
|
// `require` binding for external packages (better-sqlite3 etc.).
|
|
// `__dirname` in a chunk resolves to the bundle root, not
|
|
// `chunks/`: bundled code uses it to find siblings of the entry
|
|
// (preload.cjs, image_worker.cjs, assets/), and which chunk a
|
|
// module lands in must not change what the path means. The
|
|
// `chunks` suffix check matches `chunkNames` above.
|
|
...(esm ? {
|
|
banner: {
|
|
js: [
|
|
`import { createRequire as __bundleCreateRequire } from "node:module";`,
|
|
`import { fileURLToPath as __bundleFileURLToPath } from "node:url";`,
|
|
`import { dirname as __bundleDirname } from "node:path";`,
|
|
`const require = __bundleCreateRequire(import.meta.url);`,
|
|
`const __filename = __bundleFileURLToPath(import.meta.url);`,
|
|
`const __bundleFileDir = __bundleDirname(__filename);`,
|
|
`const __dirname = /[\\\\/]chunks$/.test(__bundleFileDir) ? __bundleDirname(__bundleFileDir) : __bundleFileDir;`
|
|
].join("\n")
|
|
}
|
|
} : importMetaUrlShim && {
|
|
banner: {
|
|
js: `const __bundleImportMetaUrl = require("node:url").pathToFileURL(__filename).href;`
|
|
}
|
|
}),
|
|
minify: true
|
|
});
|
|
writeFileSync(join(this.outDir, "meta.json"), JSON.stringify(result.metafile));
|
|
|
|
// Tesseract.js is marked as external above because its worker runs in
|
|
// a separate worker_thread. Copy the worker source, WASM core and all
|
|
// transitive runtime deps so they are available in dist/node_modules.
|
|
this.copyNodeModules([
|
|
"tesseract.js", "tesseract.js-core", "wasm-feature-detect",
|
|
"regenerator-runtime", "is-url", "bmp-js"
|
|
]);
|
|
}
|
|
|
|
buildFrontend() {
|
|
this.triggerBuildAndCopyTo("apps/client", "public/");
|
|
|
|
// pdf.js
|
|
this.triggerBuildAndCopyTo("packages/pdfjs-viewer", "pdfjs-viewer");
|
|
}
|
|
|
|
triggerBuildAndCopyTo(projectToBuild: string, destPath: string) {
|
|
const projectDir = join(this.rootDir, projectToBuild);
|
|
execSync("pnpm build", { cwd: projectDir, stdio: "inherit" });
|
|
cpSync(join(projectDir, "dist"), join(this.projectDir, "dist", destPath), { recursive: true });
|
|
}
|
|
|
|
copyNodeModules(nodeModules: string[]) {
|
|
for (const moduleName of nodeModules) {
|
|
const sourceDir = tryPath([
|
|
join(this.projectDir, "node_modules", moduleName),
|
|
join(this.rootDir, "node_modules", moduleName)
|
|
]);
|
|
|
|
const destDir = join(this.outDir, "node_modules", moduleName);
|
|
mkdirSync(destDir, { recursive: true });
|
|
cpSync(sourceDir, destDir, { recursive: true, dereference: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strips everything from the copied better-sqlite3 that a packaged artifact
|
|
* never uses. Since v13 the package bundles a prebuilt binary for all eight
|
|
* platforms it supports (~17 MB) plus the SQLite sources needed to compile
|
|
* from scratch (~10 MB), but an artifact only ever loads one binary and never
|
|
* compiles: 27 MB -> ~2.2 MB.
|
|
*
|
|
* @param opts.platform the platform being built *for*. Defaults to the host's,
|
|
* but pass it explicitly for cross-packaging.
|
|
* @param opts.arch the architecture being built *for* -- not always the host's,
|
|
* as the macOS runners are arm64 and also package darwin-x64.
|
|
* @param opts.includeMusl whether to keep the musl build alongside the glibc one
|
|
* on Linux (defaults to `true`). Only the server needs it; see below.
|
|
*/
|
|
trimBetterSqlite3(opts: { platform?: string; arch?: string; includeMusl?: boolean } = {}) {
|
|
const { platform = process.platform, arch = targetArch(), includeMusl = true } = opts;
|
|
const moduleDir = join(this.outDir, "node_modules", "better-sqlite3");
|
|
const prebuildDir = join(moduleDir, "prebuilds");
|
|
|
|
// Every v13 install ships prebuilds/, so its absence means the module that
|
|
// got copied is not the one the lockfile pins. The usual cause is a stale
|
|
// pre-v13 tree in <app>/node_modules, which copyNodeModules prefers over the
|
|
// hoisted root -- pnpm's `hoisted` linker never creates those, so anything
|
|
// there is leftover residue. Fail here rather than shipping it: a pre-v13
|
|
// module needs the `bindings` package that is no longer copied, so the
|
|
// artifact would instead die at startup with a confusing resolution error.
|
|
if (!existsSync(prebuildDir)) {
|
|
const version = JSON.parse(readFileSync(join(moduleDir, "package.json"), "utf-8")).version;
|
|
throw new Error(
|
|
`better-sqlite3 ${version} in ${moduleDir} has no prebuilds/ directory. `
|
|
+ `Expected a v13+ layout -- delete any <app>/node_modules/better-sqlite3 and reinstall.`
|
|
);
|
|
}
|
|
|
|
// Keep both libc variants on Linux by default. The server's dist is built
|
|
// once on a glibc runner and then consumed by both the Debian and the Alpine
|
|
// images, and better-sqlite3 resolves linuxmusl-* at runtime on the latter --
|
|
// dropping it there would break the amd64 image at startup. Electron-based
|
|
// artifacts have no such consumer (Electron ships no musl builds), so they
|
|
// opt out and save the extra ~2.3 MB.
|
|
const keep = new Set([ `${platform}-${arch}.node` ]);
|
|
if (platform === "linux" && includeMusl) {
|
|
keep.add(`linuxmusl-${arch}.node`);
|
|
}
|
|
|
|
const available = readdirSync(prebuildDir);
|
|
if (!available.includes(`${platform}-${arch}.node`)) {
|
|
throw new Error(
|
|
`better-sqlite3 ships no prebuild for ${platform}-${arch} (found: ${available.join(", ")}). `
|
|
+ `Refusing to trim, since that would leave the artifact with no native addon.`
|
|
);
|
|
}
|
|
|
|
for (const file of available) {
|
|
if (!keep.has(file)) {
|
|
rmSync(join(prebuildDir, file));
|
|
}
|
|
}
|
|
|
|
// Compile-only: the SQLite amalgamation, the addon's own C++ sources, the
|
|
// gyp manifest and node-gyp's scratch output. Nothing in lib/ reads them.
|
|
for (const path of [ "deps", "src", "build", "binding.gyp" ]) {
|
|
rmSync(join(moduleDir, path), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
writeJson(relativePath: string, data: any) {
|
|
const fullPath = join(this.outDir, relativePath);
|
|
const dirPath = fullPath.substring(0, fullPath.lastIndexOf("/"));
|
|
if (dirPath) {
|
|
mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
writeFileSync(fullPath, JSON.stringify(data, null, 4), "utf-8");
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* The architecture currently being built for. The release workflows set these when
|
|
* a job's target differs from the runner it happens to be executing on.
|
|
*/
|
|
function targetArch() {
|
|
return process.env.TARGET_ARCH || process.env.MATRIX_ARCH || process.arch;
|
|
}
|
|
|
|
function tryPath(paths: string[]) {
|
|
for (const path of paths) {
|
|
if (existsSync(path)) {
|
|
return path;
|
|
}
|
|
}
|
|
|
|
console.error("Unable to find any of the paths:", paths);
|
|
process.exit(1);
|
|
}
|