Files
webshare-api/scripts/build.mjs
Michal Pemcak 0dd548339c 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.
2026-08-07 11:09:02 +02:00

106 lines
3.1 KiB
JavaScript

#!/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/');