@msom/xbuild
Version:
@msom/xbuild
521 lines (492 loc) • 15.7 kB
text/typescript
import { assert, nil } from "@msom/common";
import { ProxyRules, createServer, staticMiddle } from "@msom/http";
import * as fs from "fs";
import { JSDOM } from "jsdom";
import path from "path";
import {
ExternalOption,
OutputAsset,
OutputChunk,
OutputOptions,
RolldownBuild,
RolldownOptions,
RolldownPluginOption,
rolldown,
} from "rolldown";
import postcss from "rollup-plugin-postcss";
import { Only, StringOrRegExp, getModuleName } from "../utils/common";
import { Logger } from "../utils/logger";
import { PluginManager } from "./plugin";
import {
LoadedXbuildConfig,
XBuildContext,
XBuildOutputOptions,
XbuildDevOptions,
} from "./types";
import { htmlEntryPlugin } from "./htmlEntryPlugin";
const defaultRolldownPlugins: RolldownPluginOption<unknown>[] = [
postcss({
extract: true,
sourceMap: true,
}),
htmlEntryPlugin(),
];
/**
* 打包工具相关依赖
*/
const defaultRolldownExternal = [
/^@rolldown\//,
/^rolldown/,
/^@babel\//,
/^@rollup\//,
/rollup/,
/^http/,
"net",
"autoprefixer",
"chalk",
"commander",
"less",
"postcss",
"rolldown",
"typescript",
"jsdom",
"fs",
"path",
"url",
"rollup-plugin-postcss",
];
enum FileLikeType {
File = "file",
Directory = "directory",
}
interface IFile {
name: string;
path: string;
type: FileLikeType.File;
}
interface IDirectory extends Omit<IFile, "type"> {
type: FileLikeType.Directory;
children: Tree;
}
type Tree = (IFile | IDirectory)[];
export class XBuilder {
private config: XBuildContext;
private logger: Logger = new Logger("Builder");
constructor(config: XBuildContext) {
this.config = config;
}
get pluginManager() {
return new PluginManager(this.config?.pluginManager || []);
}
private buildHtml(filePath: string) {
const fileContent = fs.readFileSync(path.resolve(filePath), "utf-8");
const html = new JSDOM(fileContent);
const script = html.window.document.querySelector(
"script#script-main[type=module]"
) as HTMLScriptElement;
if (!script) {
return filePath;
} else {
const src = script.src;
const changeString = (
str: string,
splitor: string,
replacer: ((old: string) => string) | string
) => {
const _replacer =
typeof replacer === "function" ? replacer : () => replacer;
return str
.split(splitor)
.map((v, i, a) => (i === a.length - 1 ? _replacer(v) : v))
.join(splitor);
};
const fileName = changeString(src.split("/").pop()!, "/", (v) =>
changeString(v, ".", "js")
);
script.src = "./" + fileName;
const { output } = this.rolldownOptions;
const dir = [output].flat().reduce((a, b) => b?.dir || a, "./dist");
const htmlName = filePath.split("/").pop();
assert(htmlName);
this.write(path.resolve(dir, htmlName), html.serialize(), "utf-8");
return src;
}
}
private async generate(bundle: RolldownBuild, output: OutputOptions) {
const chunk = await bundle.generate(output).then((v) => v.output);
const res = this.pluginManager.apply(
"transform",
chunk[0].code,
chunk[0].fileName,
chunk[0].map
);
Object.assign(chunk[0], res);
return chunk;
}
get rolldownOptions(): RolldownOptions {
const { config } = this;
if (!config.build) {
return {
input: "./index.html",
plugins: [...defaultRolldownPlugins],
output: [
{
dir: "./dist",
format: "esm",
},
],
};
} else {
const options = {} as RolldownOptions;
Object.assign(options, config.build);
options.output = [
options.output as Exclude<
Exclude<LoadedXbuildConfig["build"], undefined>["output"],
undefined
>,
]
.flat()
.filter(Boolean)
.map((out: XBuildOutputOptions) => {
return {
...out,
chunkFileNames:
out.chunkFileNames &&
((info) => {
const name =
typeof out.chunkFileNames === "function"
? out.chunkFileNames(info, out.format || "esm")
: out.chunkFileNames;
return name || info.name;
}),
};
});
options.plugins = [...defaultRolldownPlugins].concat(
[config.build?.plugins].flat().filter(Boolean)
);
options.external = [
...defaultRolldownExternal,
...([config.build.external].flat() as (
| StringOrRegExp
| Only<ExternalOption, Function>
)[]),
];
return options;
}
}
private write(
filePath: string,
data: string | NodeJS.ArrayBufferView,
options: fs.WriteFileOptions
) {
let _options = options as Exclude<typeof options, string | null>;
if (typeof options === "string") {
_options = {
encoding: options,
};
} else if (options === null) {
_options = {};
}
const dirpath = path.dirname(filePath);
if (!fs.existsSync(dirpath)) {
fs.mkdirSync(dirpath, { recursive: true });
}
return new Promise<void>((resolve, reject) => {
fs.writeFile(
filePath,
data,
{
encoding: "utf-8",
..._options,
},
(error) => {
if (!error) {
resolve();
} else {
reject(error);
}
}
);
if (_options.signal) {
_options.signal.addEventListener("abort", () => {
if (fs.existsSync(dirpath)) {
fs.rmSync(dirpath, { recursive: true });
}
});
}
});
}
async runBuild() {
try {
const { output, ...options } = this.rolldownOptions;
if (options.input) {
const inputs: string[] = [];
for (const input of [options.input as string | string[]].flat()) {
if (!input.endsWith(".html")) {
inputs.push(input);
} else {
inputs.push(this.buildHtml(input));
}
}
options.input = inputs;
if (inputs.length === 0) {
this.logger.success("Build completed successfully");
return true;
}
}
const inputs = options.input as string[];
for (const input of inputs) {
await this.buildOne(input);
}
this.logger.success("Build completed successfully");
return true;
} catch (error) {
this.logger.error("Build failed", error);
return false;
}
}
private async buildOne(input: string) {
const { input: _, output, ...options } = this.rolldownOptions;
const bundle = await rolldown({ ...options, input });
// 每个output都打包一次
const promiseResults = [output]
.flat()
.filter(Boolean)
.map(async (output: OutputOptions) => {
const bundled = await this.generate(bundle, output);
return {
output,
chunks: bundled.filter((v) => v.type === "chunk") as OutputChunk[],
assets: bundled.filter((v) => v.type === "asset") as OutputAsset[],
};
});
// 开始写入文件
// 等待打包完成
const result = await Promise.all(promiseResults);
const writes: Promise<unknown>[] = [];
const abortController = new AbortController();
const write = (writePath: string, content: string) => {
writes.push(
this.write(writePath, content, {
encoding: "utf-8",
signal: abortController.signal,
})
);
};
for (const { output, chunks, assets } of result) {
const { dir = "./", chunkFileNames } = output;
// 当有文件写入失败后停止写入其他文件
abortController.signal.addEventListener("abort", () => {
if (dir === "./") return;
const dirPath = path.resolve(dir);
if (fs.existsSync(dirPath)) {
fs.rmSync(dirPath, { recursive: true });
}
});
// 写chunk
chunks.forEach(
({
fileName,
facadeModuleId,
code,
sourcemapFileName,
map,
...option
}) => {
fileName = nil(
typeof chunkFileNames === "function"
? chunkFileNames({
...option,
facadeModuleId: facadeModuleId || "",
name: fileName,
})
: chunkFileNames,
fileName
);
write(path.resolve(dir, fileName), code);
if (map && sourcemapFileName) {
sourcemapFileName = path.resolve(
path.dirname(path.resolve(dir, sourcemapFileName)),
fileName + ".map"
);
write(sourcemapFileName, map.toString());
}
}
);
// 写静态资源
assets.forEach(({ source, fileName }) => {
source &&
fileName &&
write(path.resolve(dir, fileName), source.toString());
});
}
let error: any;
await Promise.all(writes)
.catch((e) => {
error = e;
abortController.abort();
})
.finally(() => {
return bundle.close();
});
if (error) {
throw error;
}
}
get defaultDevOption() {
return { port: 9999, public: "public" } as const;
}
private getDevOptions(
option: XbuildDevOptions
): Required<Omit<XbuildDevOptions, "proxy">> &
Pick<XbuildDevOptions, "proxy"> {
const dev = this.config.dev || {};
const options = {
...this.defaultDevOption,
...dev,
...option,
};
Object.keys(this.defaultDevOption).forEach((key) => {
options[key] = nil(options[key], this.defaultDevOption[key]);
});
return options;
}
async runDev(options: XbuildDevOptions) {
// 启动服务器
let promiseHandle: ((data: any) => void)[] = [];
const option = this.getDevOptions(options);
try {
createServer(option.port, {
middles: [
staticMiddle(path.resolve(process.cwd(), option.public)),
staticMiddle(path.resolve("../../template/dist")),
staticMiddle(path.resolve("../../template")),
],
routes: [
{
path: "/demo",
method: "get",
handlers: [
async (request, response) => {
const { modulePath } = request.query as { modulePath?: string };
if (!modulePath) {
response.sendStatus(404);
return;
}
const { output, ...options } = this.rolldownOptions;
options.input = path.resolve("src", modulePath);
if (!fs.existsSync(options.input)) {
response.sendStatus(404);
return;
}
const bundle = await rolldown(options);
const bundleCode = (
await bundle.generate({
format: "esm",
file: "index.js",
sourcemap: false,
})
).output[0].code;
response.send(`
<!DOCTYPE html>
<html>
<head>
<title>Component Preview - ${getModuleName(
modulePath
)}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="">
${bundleCode}
<\/script>
</body>
</html>
`);
},
],
children: [
{
path: "/file-tree",
method: "get",
handlers: [
(request, response) => {
// 在什么地方运行,root就是什么路径
const src = path.resolve(process.cwd(), "src");
/**
* 构建符合 Tree 类型的文件结构
* @param rootDir 扫描根目录
* @returns 符合 Tree 类型的文件结构
*/
function buildFileTree(rootPath: string): Tree {
if (!fs.existsSync(rootPath)) return [];
function buildTree(
currentDir: string,
relativePath: string
): Tree {
const items = fs.readdirSync(currentDir, {
withFileTypes: true,
});
const dirs: Tree = [];
const files: Tree = [];
for (const item of items) {
const itemPath = path.join(currentDir, item.name);
const itemRelativePath = relativePath
? `${relativePath}/${item.name}`
: item.name;
if (item.isDirectory()) {
const children = buildTree(
itemPath,
itemRelativePath
);
if (children.length > 0) {
dirs.push({
name: item.name,
path: itemRelativePath,
type: FileLikeType.Directory,
children,
});
}
} else if (
item.isFile() &&
/.*\.(demo|dev)\.tsx?$/.test(item.name)
) {
files.push({
name: item.name,
path: itemRelativePath,
type: FileLikeType.File,
});
}
}
// 对目录和文件分别按名称排序
dirs.sort((a, b) => a.name.localeCompare(b.name));
files.sort((a, b) => a.name.localeCompare(b.name));
// 目录在前,文件在后
return [...dirs, ...files];
}
return buildTree(rootPath, "");
}
const res = buildFileTree(src);
response.status(200).json(res);
},
],
},
],
},
],
proxy: option.proxy,
printProxy: false,
createHandle: ({ port }) => {
promiseHandle[0]({ port, proxy: option.proxy });
},
});
} catch (e) {
console.log(e);
}
return new Promise<{ port: number; proxy: ProxyRules | undefined | null }>(
(...args) => {
promiseHandle = args;
}
);
}
}