@alloc/html-bundle
Version:
Bundle your HTML assets with Esbuild and LightningCSS. Custom plugins, HMR platform, and more.
328 lines • 12.3 kB
JavaScript
import cac from 'cac';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import glob from 'glob';
import { cyan, red, yellow } from 'kleur/colors';
import md5Hex from 'md5-hex';
import * as mime from 'mrmime';
import * as path from 'path';
import { performance } from 'perf_hooks';
import { debounce } from 'ts-debounce';
import { parse as parseURL } from 'url';
import * as uuid from 'uuid';
import * as ws from 'ws';
import { compileClientModule } from './esbuild.mjs';
import { buildHTML } from './html.mjs';
import { findFreeTcpPort, loadBundleConfig, lowercaseKeys } from './utils.mjs';
const cli = cac('html-bundle');
cli
.command('')
.option('--watch', `[boolean]`)
.option('--critical', `[boolean]`)
.option('--webext <target>', 'Override webext config')
.action(async (flags) => {
process.env.NODE_ENV ||= flags.watch ? 'development' : 'production';
const config = await loadBundleConfig(flags);
glob(`${config.src}/**/*.html`, (err, files) => {
if (err) {
console.error(err);
process.exit(1);
}
build(files, config, flags);
});
});
cli.parse();
async function build(files, config, flags) {
if (config.deletePrev) {
fs.rmSync(config.build, { force: true, recursive: true });
}
let server;
if (flags.watch) {
const servePlugins = config.plugins.filter(p => p.serve);
server = await installHttpServer(config, servePlugins);
}
// At this point, the dev server URL and port are known.
config.esbuild.define['import.meta.env.DEV_URL'] = JSON.stringify(config.server.url);
config.esbuild.define['import.meta.env.HMR_PORT'] = JSON.stringify(config.server.port);
const timer = performance.now();
files = files.map(file => path.resolve(file));
await Promise.all(files.map(file => buildHTML(file, config, flags)));
console.log(cyan('build complete in %sms'), (performance.now() - timer).toFixed(2));
for (const plugin of config.plugins) {
if (!plugin.buildEnd)
continue;
await plugin.buildEnd(false);
}
if (server) {
const hmrInstances = [];
const hmrPlugins = config.plugins.filter(p => p.hmr);
if (hmrPlugins.length) {
await installWebSocketServer(server, config, hmrPlugins, hmrInstances);
}
const watcher = config.watcher;
const changedFiles = new Set();
watcher.on('add', async (file) => {
await rebuild();
console.log(cyan('+'), file);
});
watcher.on('change', async (file) => {
changedFiles.add(file);
await rebuild();
});
watcher.on('unlink', async (file) => {
const outPath = config.getBuildPath(file).replace(/\.[jt]sx?$/, '.js');
try {
fs.rmSync(outPath);
let outDir = path.dirname(outPath);
while (outDir !== config.build) {
const stats = fs.readdirSync(outDir);
if (stats.length)
break;
fs.rmSync(outDir);
outDir = path.dirname(outDir);
}
}
catch { }
console.log(red('–'), file);
});
const rebuild = debounce(async () => {
console.clear();
let needRebuild = false;
const acceptedFiles = new Map();
accept: for (const file of changedFiles) {
console.log(cyan('↺'), file);
for (const hmr of hmrInstances) {
if (hmr.accept(file)) {
let files = acceptedFiles.get(hmr);
if (!files) {
acceptedFiles.set(hmr, (files = []));
}
files.push(file);
continue accept;
}
}
needRebuild = true;
break;
}
changedFiles.clear();
if (needRebuild) {
config.events.emit('will-rebuild');
const timer = performance.now();
await Promise.all(files.map(file => buildHTML(file, config, flags)));
config.events.emit('rebuild');
for (const plugin of config.plugins) {
if (!plugin.buildEnd)
continue;
await plugin.buildEnd(true);
}
console.log(cyan('build complete in %sms'), (performance.now() - timer).toFixed(2));
}
else {
await Promise.all(Array.from(acceptedFiles, ([hmr, files]) => hmr.update(files)));
}
console.log(yellow('watching files...'));
}, 200);
console.log(yellow('watching files...'));
}
}
async function installHttpServer(config, servePlugins) {
let createServer;
let serverOptions;
if (config.server.https) {
createServer = (await import('https')).createServer;
serverOptions = config.server.https;
if (!serverOptions.cert) {
const cert = await getCertificate('node_modules/.html-bundle/self-signed');
serverOptions.cert = cert;
serverOptions.key = cert;
}
}
else {
createServer = (await import('http')).createServer;
serverOptions = {};
}
// The dev server allows access to files within these directories.
const fsAllow = new RegExp(`^/(${[config.build, config.assets].join('|')})/`);
const server = createServer(serverOptions, async (req, response) => {
const request = Object.assign(req, parseURL(req.url));
request.searchParams = new URLSearchParams(request.search || '');
let file = null;
for (const plugin of servePlugins) {
file = (await plugin.serve(request, response)) || null;
if (response.headersSent)
return;
if (file)
break;
}
// If no plugin handled the request, check the virtual filesystem.
if (!file) {
const uri = request.pathname;
let virtualFile = config.virtualFiles[uri];
if (virtualFile) {
if (typeof virtualFile == 'function') {
virtualFile = virtualFile(request);
}
file = await virtualFile;
}
// If no virtual file exists, check the local filesystem.
if (!file && fsAllow.test(uri)) {
try {
file = {
data: fs.readFileSync('.' + uri),
};
}
catch { }
}
}
if (file) {
const headers = (file.headers && lowercaseKeys(file.headers)) || {};
headers['access-control-allow-origin'] ||= '*';
headers['cache-control'] ||= 'no-store';
headers['content-type'] ||=
mime.lookup(file.path || request.pathname) || 'application/octet-stream';
response.statusCode = 200;
for (const [name, value] of Object.entries(headers)) {
response.setHeader(name, value);
}
response.end(file.data);
return;
}
console.log(red('404: %s'), req.url);
response.statusCode = 404;
response.end();
});
const protocol = config.server.https ? 'https' : 'http';
const port = config.server.port == 0
? (config.server.port = await findFreeTcpPort())
: config.server.port;
config.server.url = new URL(`${protocol}://localhost:${port}`);
server.listen(port, () => {
console.log(cyan('%s server listening on port %s'), protocol, port);
});
return server;
}
async function installWebSocketServer(server, config, hmrPlugins, hmrInstances) {
const events = new EventEmitter();
const clients = new Set();
const requests = {};
const context = clients;
context.on = events.on.bind(events);
hmrPlugins.forEach(plugin => {
const instance = plugin.hmr(context);
if (instance) {
hmrInstances.push(instance);
}
});
const evaluate = (client, src, args = []) => {
return new Promise(resolve => {
const id = uuid.v4();
requests[id] = resolve;
client.pendingRequests.add(id);
client.socket.send(JSON.stringify({
id,
src: new URL(src, config.server.url).href,
args,
}));
});
};
const compiledModules = new Map();
const runningModules = new Map();
class Client extends EventEmitter {
socket;
pendingRequests = new Set();
constructor(socket) {
super();
this.socket = socket;
}
evaluate(expr) {
const path = `/${md5Hex(expr)}.js`;
config.virtualFiles[path] ||= {
data: `export default () => ${expr}`,
};
return evaluate(this, path);
}
async evaluateModule(file, args) {
const moduleUrl = new URL(file, import.meta.url);
const mtime = fs.statSync(moduleUrl).mtimeMs;
const path = `/${md5Hex(moduleUrl.href)}.${mtime}.js`;
if (config.virtualFiles[path] == null) {
let compiled = compiledModules.get(moduleUrl.href);
if (compiled?.mtime != mtime) {
const data = await compileClientModule(file, config, 'esm');
compiledModules.set(moduleUrl.href, (compiled = {
path: moduleUrl.pathname,
mtime,
data,
}));
}
config.virtualFiles[path] = compiled;
}
let parallelCount = runningModules.get(path) || 0;
runningModules.set(path, parallelCount + 1);
const result = await evaluate(this, path, args);
parallelCount = runningModules.get(path);
runningModules.set(path, --parallelCount);
if (parallelCount == 0) {
delete config.virtualFiles[path];
}
return result;
}
getURL() {
return this.evaluate('location.href');
}
reload() {
return this.evaluate('location.reload()');
}
}
const wss = new ws.WebSocketServer({ server });
wss.on('connection', socket => {
const client = new Client(socket);
clients.add(client);
socket.on('close', () => {
for (const id of client.pendingRequests) {
requests[id](null);
delete requests[id];
}
clients.delete(client);
});
socket.on('message', data => {
const event = JSON.parse(data.toString());
if (event.type == 'result') {
client.pendingRequests.delete(event.id);
requests[event.id](event.result);
delete requests[event.id];
}
else {
event.client = client;
client.emit(event.type, event);
events.emit(event.type, event);
}
});
events.emit('connect', {
type: 'connect',
client,
});
});
}
async function getCertificate(cacheDir) {
const cachePath = path.join(cacheDir, '_cert.pem');
try {
const stat = fs.statSync(cachePath);
const content = fs.readFileSync(cachePath, 'utf8');
if (Date.now() - stat.ctime.valueOf() > 30 * 24 * 60 * 60 * 1000) {
throw 'Certificate is too old';
}
return content;
}
catch {
const content = (await import('./https/createCertificate.mjs')).createCertificate();
try {
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, content);
}
catch { }
return content;
}
}
//# sourceMappingURL=bundle.mjs.map