// Load compiled plugin instances outside the app, for the novel CLI scripts. // npm packages stay external, so all plugins share one copy of cheerio & co. from // node_modules (bundled standalone, each plugin carried its own: ~1.2 GB for 165). // The bundle is built once per source revision and cached with an id -> file index, // so fetching a novel loads a single plugin. The cache lives inside the repo so the // bundles resolve node_modules from it. import * as esbuild from 'esbuild'; import { execFileSync } from 'child_process'; import { createHash } from 'crypto'; import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import path, { dirname } from 'path'; import fs from 'fs'; const require = createRequire(import.meta.url); export const ROOT = path.resolve(dirname(fileURLToPath(import.meta.url)), '..'); const CACHE = process.env.NOVEL_PLUGIN_CACHE || path.join(ROOT, '.plugin-cache'); export const LANGS = ['english', 'multi']; // The shared fetch helper logs every request with its headers (credentials // included). Callers print their output explicitly with `print`. export const print = (...args) => process.stdout.write(args.join(' ') + '\n'); console.log = () => undefined; const git = args => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim(); /** Cache key: the commit plus any uncommitted changes to plugins or libs. */ function revision(langs) { let head = 'nogit'; let dirty = ''; try { head = git(['rev-parse', 'HEAD']); dirty = git(['diff', 'HEAD', '--', 'plugins', 'src']); } catch { // Not a git checkout: the cache is keyed by language set only. } return createHash('sha256') .update(`${head}\n${dirty}\n${langs.join(',')}`) .digest('hex') .slice(0, 16); } /** Build (once per revision) and return the cache dir holding bundles + index.json. */ async function bundle(langs) { const dir = path.join(CACHE, revision(langs)); if (fs.existsSync(path.join(dir, 'index.json'))) return dir; // Multisrc plugins (Madara, NovelFire, ...) are generated, not committed. execFileSync('node', ['plugins/multisrc/generate-multisrc-plugins.js'], { cwd: ROOT, stdio: 'ignore', }); const entryPoints = langs.flatMap(lang => { const src = path.join(ROOT, 'plugins', lang); if (!fs.existsSync(src)) throw new Error(`No plugin folder: plugins/${lang}`); return fs .readdirSync(src) .filter(f => f.endsWith('.ts')) .map(f => path.join(src, f)); }); const tmp = `${dir}.tmp-${process.pid}`; fs.rmSync(tmp, { recursive: true, force: true }); const result = await esbuild.build({ entryPoints, outdir: tmp, bundle: true, platform: 'node', format: 'cjs', target: 'node22', outExtension: { '.js': '.cjs' }, packages: 'external', logLevel: 'silent', alias: { '@libs': path.join(ROOT, 'src/libs'), '@': path.join(ROOT, 'src'), }, }); if (result.errors.length) throw new Error('Plugin bundling failed'); const index = {}; for (const file of fs.readdirSync(tmp, { recursive: true })) { if (!file.endsWith('.cjs')) continue; try { const mod = require(path.join(tmp, file)); const plugin = mod.default ?? mod; if (plugin?.id) index[plugin.id] = file; } catch { // A plugin that throws on load is skipped, like a broken source in the app. } } fs.writeFileSync(path.join(tmp, 'index.json'), JSON.stringify(index)); fs.rmSync(dir, { recursive: true, force: true }); fs.renameSync(tmp, dir); return dir; } function instantiate(dir, file) { const mod = require(path.join(dir, file)); return mod.default ?? mod; } /** Every plugin in the given language folders (for searching across sources). */ export async function loadPlugins(langs = LANGS) { const dir = await bundle(langs); const index = JSON.parse(fs.readFileSync(path.join(dir, 'index.json'))); const plugins = []; for (const file of Object.values(index)) { try { plugins.push(instantiate(dir, file)); } catch { // Skipped, as at bundle time. } } return plugins; } /** One plugin by id, loading nothing else. */ export async function loadPlugin(id, langs = LANGS) { const dir = await bundle(langs); const file = JSON.parse(fs.readFileSync(path.join(dir, 'index.json')))[id]; if (!file) throw new Error(`No plugin with id "${id}"`); return instantiate(dir, file); }