Add esbuild production bundle and multi-stage slim Docker image.
Bundle server (and mcp entry) into a single JS file with deps inlined, minify UI into one HTML, and ship only dist/ in the runtime image.
This commit is contained in:
105
scripts/build.mjs
Normal file
105
scripts/build.mjs
Normal file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Production build: one Node bundle (all deps inlined) + minified single-file Web UI.
|
||||
*
|
||||
* Outputs:
|
||||
* dist/server.js — main app (Torznab, qBt, MCP, UI API)
|
||||
* dist/mcp.js — optional MCP-only process
|
||||
* dist/ui/index.html — Search/Queue UI (CSS+JS inlined)
|
||||
*/
|
||||
import * as esbuild from 'esbuild';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dist = path.join(root, 'dist');
|
||||
const distUi = path.join(dist, 'ui');
|
||||
|
||||
fs.rmSync(dist, { recursive: true, force: true });
|
||||
fs.mkdirSync(distUi, { recursive: true });
|
||||
|
||||
const nodeBundle = {
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'cjs',
|
||||
minify: true,
|
||||
legalComments: 'none',
|
||||
sourcemap: false,
|
||||
// Keep Node builtins external; pull npm deps into the file.
|
||||
packages: 'bundle',
|
||||
};
|
||||
|
||||
async function bundleServer() {
|
||||
const result = await esbuild.build({
|
||||
...nodeBundle,
|
||||
entryPoints: [path.join(root, 'src/index.js')],
|
||||
outfile: path.join(dist, 'server.js'),
|
||||
metafile: true,
|
||||
logLevel: 'info',
|
||||
});
|
||||
const out = result.metafile.outputs[Object.keys(result.metafile.outputs)[0]];
|
||||
console.log(`server.js ${(out.bytes / 1024 / 1024).toFixed(2)} MB`);
|
||||
}
|
||||
|
||||
async function bundleMcp() {
|
||||
const result = await esbuild.build({
|
||||
...nodeBundle,
|
||||
entryPoints: [path.join(root, 'src/mcp.js')],
|
||||
outfile: path.join(dist, 'mcp.js'),
|
||||
banner: { js: '#!/usr/bin/env node\n' },
|
||||
metafile: true,
|
||||
logLevel: 'info',
|
||||
});
|
||||
const out = result.metafile.outputs[Object.keys(result.metafile.outputs)[0]];
|
||||
console.log(`mcp.js ${(out.bytes / 1024 / 1024).toFixed(2)} MB`);
|
||||
try {
|
||||
fs.chmodSync(path.join(dist, 'mcp.js'), 0o755);
|
||||
} catch {
|
||||
/* windows */
|
||||
}
|
||||
}
|
||||
|
||||
async function bundleUi() {
|
||||
const cssSrc = fs.readFileSync(path.join(root, 'ui/styles.css'), 'utf8');
|
||||
const jsSrc = fs.readFileSync(path.join(root, 'ui/scripts.js'), 'utf8');
|
||||
const htmlSrc = fs.readFileSync(path.join(root, 'ui/index.html'), 'utf8');
|
||||
|
||||
const css = await esbuild.transform(cssSrc, { loader: 'css', minify: true });
|
||||
const js = await esbuild.transform(jsSrc, {
|
||||
loader: 'js',
|
||||
minify: true,
|
||||
target: ['es2020'],
|
||||
});
|
||||
|
||||
// Prefer a single HTML payload (no extra static requests) for the tiny UI.
|
||||
let html = htmlSrc
|
||||
.replace(/<link rel="stylesheet" href="styles\.css">\s*/i, '')
|
||||
.replace(/<script src="scripts\.js"><\/script>\s*/i, '');
|
||||
|
||||
// Inject minified assets before </head> and </body>
|
||||
if (!html.includes('</head>')) {
|
||||
throw new Error('ui/index.html missing </head>');
|
||||
}
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<style>${css.code}</style>\n</head>`,
|
||||
);
|
||||
if (!html.includes('</body>')) {
|
||||
throw new Error('ui/index.html missing </body>');
|
||||
}
|
||||
html = html.replace(
|
||||
'</body>',
|
||||
`<script>${js.code}</script>\n</body>`,
|
||||
);
|
||||
|
||||
const outHtml = path.join(distUi, 'index.html');
|
||||
fs.writeFileSync(outHtml, html);
|
||||
console.log(`ui/index.html ${(Buffer.byteLength(html) / 1024).toFixed(1)} KB (inlined css+js)`);
|
||||
}
|
||||
|
||||
await bundleServer();
|
||||
await bundleMcp();
|
||||
await bundleUi();
|
||||
console.log('build ok → dist/');
|
||||
Reference in New Issue
Block a user