0fd7b27d08
Bundled standalone, every plugin carried its own cheerio/htmlparser2/etc.: loading 165 plugins took ~13 s and 1.2 GB (2.2 GB while building). With npm packages external they share node_modules: 0.5 s / 104 MB (build 1.5 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
4.3 KiB
JavaScript
128 lines
4.3 KiB
JavaScript
// 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);
|
|
}
|