refactor: 构建脚本

This commit is contained in:
AncTe committed 2026-10-20 19:24:55 +08:00
1 parent d35f8303da
commit 3cc97e5ed3
8 files changed
+672 -833

No files matched your search

+141 -570
View File
@@ -1,21 +1,10 @@
import { build, loadConfigFromFile, mergeConfig, UserConfig } from 'vite';
import legacy from '@vitejs/plugin-legacy';
import { resolve } from 'path';
import { copy, emptyDir, ensureDir, pathExists } from 'fs-extra';
import { OutputAsset, OutputChunk, RollupOutput } from 'rollup';
import Fontmin from 'fontmin';
import { readdir, readFile, rmdir, stat, writeFile } from 'fs/promises';
import { transformAsync } from '@babel/core';
import archiver from 'archiver';
import { createWriteStream } from 'fs';
import { zip } from 'compressing';
import { RequiredData, RequiredIconsData } from './types';
import {
CompressedUsage,
LoadDataType,
splitResource,
SplittedResource
} from './build-resource';
import { copy, emptyDir, ensureDir } from 'fs-extra';
import { readdir, readFile, stat } from 'fs/promises';
import { dataIsolationPlugin } from './build-plugin';
import { buildContent } from './build-resource';
import { formatSize } from './utils';
/** 打包调试 */
@@ -27,15 +16,56 @@ const ansi = {
clear: '\x1b[2J\x1b[0f'
};
// 资源分离步骤的单包大小,默认 2M,可以自行调整
const RESOUCE_SIZE = 2 * 2 ** 20;
const distDir = resolve(process.cwd(), 'dist');
const tempDir = resolve(process.cwd(), '_temp');
const enum ProgressStatus {
Success,
Fail,
Working,
Warn
}
/**
* 构建游戏代码
* @param entry 入口文件
* 输出构建步骤
* @param step 步骤数
* @param final 最后一步的状态
*/
function logProgress(step: number, final: ProgressStatus) {
const list = [
`1. 构建前准备`,
`2. 构建渲染端`,
`3. 构建数据端`,
`4. 处理 content`,
`5. 收尾`
];
const str = list.reduce((prev, curr, idx) => {
if (idx > step) {
return prev;
} else if (idx === step) {
switch (final) {
case ProgressStatus.Fail:
return prev + `❌ ${curr}\r\n错误信息:\r\n`;
case ProgressStatus.Success:
return prev + `✅ ${curr}\r\n`;
case ProgressStatus.Working:
return prev + `🔄 ${curr}\r\n`;
case ProgressStatus.Warn:
return prev + `⚠️ ${curr}\r\n`;
}
} else {
return prev + `✅ ${curr}\r\n`;
}
}, '');
process.stdout.write(ansi.clear);
process.stdout.write(str);
}
/**
* 构建渲染端代码,以 index.html 为默认入口输出到 dist
* @param outDir 输出目录
*/
async function buildClient(outDir: string) {
const configFile = resolve(process.cwd(), 'vite.config.ts');
@@ -59,7 +89,8 @@ async function buildClient(outDir: string) {
],
build: {
outDir,
copyPublicDir: true,
copyPublicDir: false,
emptyOutDir: false,
minify: !DEBUG_BUILD,
rollupOptions: {
external: ['@wasm-audio-decoders/opus-ml'],
@@ -88,6 +119,11 @@ async function buildClient(outDir: string) {
});
}
/**
* 以库模式构建数据端代码,入口产物固定为 dist/data.js
* @param outDir 输出目录
* @param entry 数据端入口文件
*/
async function buildData(outDir: string, entry: string) {
const configFile = resolve(process.cwd(), 'vite.config.ts');
const config = await loadConfigFromFile(
@@ -95,16 +131,24 @@ async function buildData(outDir: string, entry: string) {
configFile
);
const resolved = mergeConfig(config?.config ?? {}, {
plugins: [dataIsolationPlugin()],
build: {
outDir,
copyPublicDir: false,
emptyOutDir: false,
minify: !DEBUG_REPLAY,
lib: {
entry,
name: 'ProcessData',
fileName: 'data',
formats: ['iife']
formats: ['es']
},
minify: !DEBUG_REPLAY
rollupOptions: {
output: {
entryFileNames: 'data.js',
chunkFileNames: 'data/[name].[hash].js',
assetFileNames: 'data/[name].[hash][extname]'
}
}
}
} satisfies UserConfig);
@@ -114,590 +158,117 @@ async function buildData(outDir: string, entry: string) {
});
}
const enum ProgressStatus {
Success,
Fail,
Working,
Warn
}
/**
* 输出构建步骤
* @param step 步骤数
* @param final 最后一步的状态
* 递归收集目录下的文件
* @param dir 待遍历目录
* @param output 收集结果
* @param extension 需要保留的扩展名
* @param ignored 需要跳过的目录
*/
function logProgress(step: number, final: ProgressStatus) {
const list = [
`1. 构建前准备`,
`2. 构建客户端代码`,
`3. 构建数据端代码`,
`4. 压缩 main.js`,
`5. 压缩字体`,
`6. 资源分块`,
`7. 最后处理`,
`8. 压缩为 zip`
];
const str = list.reduce((prev, curr, idx) => {
if (idx > step) {
return prev;
} else if (idx === step) {
switch (final) {
case ProgressStatus.Fail:
return prev + `❌ ${curr}\r\n错误信息:\r\n`;
case ProgressStatus.Success:
return prev + `✅ ${curr}\r\n`;
case ProgressStatus.Working:
return prev + `🔄 ${curr}\r\n`;
case ProgressStatus.Warn:
return prev + `⚠️ ${curr}\r\n`;
}
} else {
return prev + `✅ ${curr}\r\n`;
}
}, '');
process.stdout.write(ansi.clear);
process.stdout.write(str);
}
/**
* 获取规范化文件名
* @param output Rollup 输出
* @param client 是否是客户端代码
*/
function getFileName(output: OutputChunk | OutputAsset, client: boolean) {
const name = output.fileName;
if (name.startsWith('index-legacy') && client) {
return 'main';
}
if (name.startsWith('data') && !client) {
return 'main';
}
if (name.startsWith('index.html') && client) {
return 'index';
}
const index = name.indexOf('-legacy');
const unhash = name.slice(0, index);
return unhash;
}
/**
* 获取文件大小
* @param output Rollup 输出
*/
function getFileSize(output: OutputChunk | OutputAsset) {
if (output.type === 'asset') {
if (typeof output.source === 'string') {
return Buffer.byteLength(output.source);
} else {
return output.source.byteLength;
}
} else {
return Buffer.byteLength(output.code);
}
}
const enum ClientDataLevel {
Error,
Suspect,
Pass
}
/**
* 检查客户端与数据端文件大小,并给出数据端引用客户端的可能性
* @param client 客户端文件大小
* @param data 数据端文件大小
*/
function checkClientData(
client: Map<string, number>,
data: Map<string, number>
async function collectFiles(
dir: string,
output: string[],
extension: string,
ignored: string[] = []
) {
let error = false;
let warn = false;
const clientMain = client.get('main');
const dataMain = data.get('main');
if (clientMain && dataMain) {
if (clientMain <= dataMain) {
error = true;
} else if (clientMain / 2 < dataMain) {
warn = true;
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = resolve(dir, entry.name);
if (entry.isDirectory()) {
if (ignored.includes(full)) continue;
await collectFiles(full, output, extension, ignored);
} else if (entry.name.endsWith(extension)) {
output.push(full);
}
}
let clientTotal = 0;
let dataTotal = 0;
client.forEach(v => {
clientTotal += v;
});
data.forEach(v => {
dataTotal += v;
});
if (clientTotal <= dataTotal) {
error = true;
}
if (clientTotal / 4 < dataTotal) {
warn = true;
}
if (error) return ClientDataLevel.Error;
else if (warn) return ClientDataLevel.Suspect;
else return ClientDataLevel.Pass;
}
async function getAllChars(client: RollupOutput[]) {
/**
* 收集渲染端产物与 content 中出现的全部字符,用于字体子集化
*/
async function getAllChars(): Promise<string> {
const chars = new Set<string>();
// 1. 客户端构建结果
client.forEach(v => {
v.output.forEach(v => {
if (v.type === 'chunk' && v.fileName.startsWith('index')) {
const set = new Set(v.code);
set.forEach(v => chars.add(v));
}
});
});
// 2. 样板内容
const files: string[] = [];
files.push(resolve(tempDir, 'client/main.js'));
files.push(resolve(tempDir, 'client/project/data.js'));
files.push(resolve(tempDir, 'client/project/enemys.js'));
files.push(resolve(tempDir, 'client/project/events.js'));
files.push(resolve(tempDir, 'client/project/functions.js'));
files.push(resolve(tempDir, 'client/project/icons.js'));
files.push(resolve(tempDir, 'client/project/items.js'));
files.push(resolve(tempDir, 'client/project/maps.js'));
await collectFiles(distDir, files, '.js', [
resolve(distDir, 'data'),
resolve(distDir, 'content')
]);
await collectFiles(resolve(process.cwd(), 'src/content'), files, '.jsonc');
const floors = await readdir(resolve(tempDir, 'client/project/floors'));
const ids = floors.map(v => resolve(tempDir, 'client/project/floors', v));
files.push(...ids);
for (const file of files) {
const text = await readFile(file, 'utf-8');
for (const char of text) chars.add(char);
}
const libs = await readdir(resolve(tempDir, 'client/libs'));
files.push(...libs.map(v => resolve(tempDir, 'client/libs', v)));
await Promise.all(
files.map(async v => {
const stats = await stat(v);
if (!stats.isFile()) return;
const file = await readFile(v, 'utf-8');
const set = new Set(file);
set.forEach(v => chars.add(v));
})
);
return chars;
return [...chars].join('');
}
interface CompressedLoadListItem {
readonly readAs: LoadDataType;
readonly name: string;
readonly usage: CompressedUsage;
}
type CompressedLoadList = Record<string, CompressedLoadListItem[]>;
/**
* 生成资源地图 json 文件
* 递归统计目录体积
* @param dir 待统计目录
*/
function generateResourceJSON(resources: SplittedResource[]) {
const list: CompressedLoadList = {};
resources.forEach(file => {
const uri = `project/resource/${file.fileName}`;
file.content.forEach(content => {
const item: CompressedLoadListItem = {
readAs: content.readAs,
name: content.name,
usage: content.usage
};
list[uri] ??= [];
list[uri].push(item);
});
});
return JSON.stringify(list);
async function getDirectorySize(dir: string): Promise<number> {
const entries = await readdir(dir, { withFileTypes: true });
let total = 0;
for (const entry of entries) {
const full = resolve(dir, entry.name);
if (entry.isDirectory()) {
total += await getDirectorySize(full);
} else {
total += (await stat(full)).size;
}
}
return total;
}
async function buildGame() {
logProgress(0, ProgressStatus.Working);
//#region 准备步骤
try {
await ensureDir(distDir);
await ensureDir(tempDir);
await emptyDir(distDir);
await emptyDir(tempDir);
await ensureDir(resolve(tempDir, 'fonts'));
await ensureDir(resolve(tempDir, 'common'));
await ensureDir(resolve(tempDir, 'resource'));
} catch (e) {
logProgress(0, ProgressStatus.Fail);
process.stderr.write(String(e));
process.exit(1);
}
await ensureDir(distDir);
await ensureDir(tempDir);
await emptyDir(distDir);
await emptyDir(tempDir);
logProgress(1, ProgressStatus.Working);
//#region 构建客户端
const clientPack = await buildClient(resolve(tempDir, 'client')).catch(
reason => {
logProgress(1, ProgressStatus.Fail);
process.stderr.write(String(reason));
process.exit(1);
}
);
await buildClient(distDir);
logProgress(2, ProgressStatus.Working);
//#region 构建数据端
const dataPack = await buildData(
resolve(tempDir, 'data'),
resolve(process.cwd(), 'src/data.ts')
).catch(reason => {
logProgress(2, ProgressStatus.Fail);
process.stderr.write(String(reason));
process.exit(1);
});
const clientSize = new Map<string, number>();
const dataSize = new Map<string, number>();
const clientPackArr = [];
const dataPackArr = [];
// 判断客户端与数据端的构建包大小,从而推断是否出现了数据端引用客户端的问题
if (clientPack instanceof Array) {
clientPackArr.push(...clientPack);
} else if ('close' in clientPack) {
// pass.
} else {
clientPackArr.push(clientPack);
}
if (dataPack instanceof Array) {
dataPackArr.push(...dataPack);
} else if ('close' in dataPack) {
// pass.
} else {
dataPackArr.push(dataPack);
}
// 获取每个 chunk 的大小
clientPackArr.forEach(v => {
v.output.forEach(v => {
const name = getFileName(v, true);
const size = getFileSize(v);
clientSize.set(name, size);
});
});
dataPackArr.forEach(v => {
v.output.forEach(v => {
const name = getFileName(v, false);
const size = getFileSize(v);
dataSize.set(name, size);
});
});
const level = checkClientData(clientSize, dataSize);
if (level === ClientDataLevel.Error) {
logProgress(2, ProgressStatus.Fail);
process.stderr.write(
`数据端似乎引用了客户端内容,请仔细检查后再构建!`
);
process.exit(1);
}
// 解析全塔属性
const dataFile = await readFile(
resolve(process.cwd(), 'public/project/data.js'),
'utf-8'
);
const dataObject: RequiredData = JSON.parse(
dataFile.split('\n').slice(1).join('\n')
);
const mainData = dataObject.main;
await buildData(distDir, resolve(process.cwd(), 'src/data.ts'));
logProgress(3, ProgressStatus.Working);
//#region 压缩 main
try {
const main = await readFile(
resolve(tempDir, 'client/main.js'),
'utf-8'
);
const [head, tail] = main.split('// >>>> body end');
const transformed = await transformAsync(tail, {
presets: [['@babel/preset-env']],
sourceType: 'script',
minified: true,
comments: false
});
if (!transformed || !transformed.code) {
throw new ReferenceError(
`Cannot write main.js since transform result is empty.`
);
}
const code = transformed.code;
const modifiedHead = head.replace(
'this.scriptCompress = false;',
'this.scriptCompress = true;'
);
await writeFile(
resolve(tempDir, 'common/main.js'),
modifiedHead + '\n// >>>> body end\n' + code,
'utf-8'
);
} catch (e) {
logProgress(3, ProgressStatus.Fail);
process.stderr.write(String(e));
process.exit(1);
}
//#region 压缩字体
const chars = await getAllChars(clientPackArr).catch(reason => {
logProgress(4, ProgressStatus.Fail);
process.stderr.write(String(reason));
process.exit(1);
});
const { fonts } = mainData;
await Promise.all(
fonts.map(v => {
const fontmin = new Fontmin();
const src = resolve(tempDir, 'client/project/fonts', v);
const dest = resolve(tempDir, 'fonts');
const plugin = Fontmin.glyph({
text: [...chars].join('')
});
fontmin.src(src).dest(dest).use(plugin);
return fontmin.runAsync();
})
).catch(reason => {
logProgress(4, ProgressStatus.Fail);
process.stderr.write(String(reason));
process.exit(1);
const chars = await getAllChars();
const content = await buildContent({
sourceDir: resolve(process.cwd(), 'src/content'),
outputDir: resolve(distDir, 'content'),
chars
});
logProgress(5, ProgressStatus.Working);
logProgress(4, ProgressStatus.Working);
//#region 资源分块
const iconsFile = await readFile(
resolve(process.cwd(), 'public/project/icons.js'),
'utf-8'
);
const iconsObject: RequiredIconsData = JSON.parse(
iconsFile.split('\n').slice(1).join('\n')
);
const resources = await splitResource(
dataObject,
iconsObject,
resolve(tempDir, 'client'),
resolve(tempDir, 'fonts'),
RESOUCE_SIZE
).catch(reason => {
logProgress(5, ProgressStatus.Fail);
process.stderr.write(String(reason));
process.exit(1);
});
await copy(resolve(process.cwd(), 'LICENSE'), resolve(distDir, 'LICENSE'));
await Promise.all(
resources.map(v =>
writeFile(resolve(tempDir, 'resource', v.fileName), v.buffer)
)
).catch(reason => {
logProgress(5, ProgressStatus.Fail);
process.stderr.write(String(reason));
process.exit(1);
});
logProgress(4, ProgressStatus.Success);
logProgress(6, ProgressStatus.Working);
const dataStats = await stat(resolve(distDir, 'data.js'));
const totalSize = await getDirectorySize(distDir);
//#region 最后处理
const toCopy = [
'libs',
'_server',
'extensions',
'index.html',
'editor.html',
'styles.css',
'logo.png',
'project/floors',
'project/data.js',
'project/enemys.js',
'project/events.js',
'project/functions.js',
'project/icons.js',
'project/items.js',
'project/maps.js',
'project/plugins.js',
'project/materials/airwall.png'
];
clientPackArr.forEach(v => {
v.output.forEach(v => {
toCopy.push(v.fileName);
});
});
try {
await Promise.all(
toCopy.map(v =>
copy(resolve(tempDir, 'client', v), resolve(distDir, v))
)
);
await copy(
resolve(tempDir, 'resource'),
resolve(distDir, 'project/resource')
);
await copy(
resolve(tempDir, 'common/main.js'),
resolve(distDir, 'main.js')
);
await copy(
resolve(tempDir, 'data/data.iife.js'),
resolve(distDir, 'data.process.js')
);
await Promise.all(
dataObject.main.bgms.map(v =>
copy(
resolve(tempDir, 'client/project/bgms', v),
resolve(distDir, 'project/bgms', v)
)
)
);
const scripts = archiver('zip', {
store: false,
zlib: {
level: 9
}
});
scripts.directory(resolve(process.cwd(), 'packages'), 'packages/');
scripts.directory(
resolve(process.cwd(), 'packages-user'),
'packages-user/'
);
scripts.directory(resolve(process.cwd(), 'src'), '/src');
scripts.file(resolve(process.cwd(), 'public', 'main.js'), {
name: 'main.js'
});
const output = createWriteStream(resolve(distDir, 'source-code.zip'));
scripts.pipe(output);
output.on('error', err => {
throw err;
});
await new Promise<void>(res => {
output.on('finish', () => res());
scripts.finalize();
});
const json = generateResourceJSON(resources);
await writeFile(resolve(distDir, 'loadList.json'), json, 'utf-8');
await copy(
resolve(process.cwd(), 'LICENSE'),
resolve(distDir, 'LICENSE')
);
await copy(
resolve(process.cwd(), 'script/template/启动服务.exe'),
resolve(distDir, '启动服务.exe')
);
const bgPath = 'project/images/bg.jpg';
if (await pathExists(resolve(tempDir, 'client', bgPath))) {
await copy(
resolve(tempDir, 'client', bgPath),
resolve(distDir, bgPath)
);
}
} catch (e) {
logProgress(6, ProgressStatus.Fail);
process.stderr.write(String(e));
process.exit(1);
}
logProgress(7, ProgressStatus.Working);
//#region 压缩游戏
try {
await zip.compressDir(
resolve(distDir),
resolve(process.cwd(), 'dist.zip'),
{
compress: true,
compressionLevel: 9
}
);
await emptyDir(tempDir);
await rmdir(tempDir);
} catch (e) {
logProgress(7, ProgressStatus.Fail);
process.stderr.write(String(e));
process.exit(1);
}
//#region 输出构建信息
const sourceStats = await stat(resolve(distDir, 'source-code.zip'));
const sourceSize = sourceStats.size;
const zipStats = await stat(resolve(process.cwd(), 'dist.zip'));
const zipSize = zipStats.size;
const resourceSize = resources.reduce(
(prev, curr) => prev + curr.byteLength,
0
);
process.stdout.write(ansi.clear);
process.stdout.write(`✅ 构建已完成!\r\n`);
if (zipSize > 100 * 2 ** 20) {
process.stdout.write(
`⚠️ 压缩包大于 100M,可能导致发塔困难,请考虑降低塔的大小\r\n`
);
const suggections: string[] = [];
if (dataObject.main.bgms.some(v => !v.endsWith('opus'))) {
suggections.push(`将 BGM 和音效换用 opus 格式`);
}
if (dataObject.main.images.some(v => !v.endsWith('webp'))) {
suggections.push(`将图片换用无损 webp 格式`);
}
if (suggections.length > 0) {
process.stdout.write(`降低压缩包体积的可能方案:\r\n`);
suggections.forEach((v, i) => {
process.stdout.write(`${i + 1}. ${v}\r\n`);
});
}
}
process.stdout.write(`压缩包大小:${formatSize(zipSize)}\r\n`);
process.stdout.write(`源码大小:${formatSize(sourceSize)}\r\n`);
process.stdout.write(`资源大小:${formatSize(resourceSize)}\r\n`);
resources.forEach(v => {
process.stdout.write(
`--> ${v.fileName} ${formatSize(v.byteLength)} | ${v.content.length} 个资源\r\n`
);
});
process.stdout.write(
`数据端入口:data.js(${formatSize(dataStats.size)})\r\n`
);
process.stdout.write(
`content:${content.files} 个文件,其中 ${content.jsonc} 个 jsonc、${content.fonts} 个字体\r\n`
);
process.stdout.write(`dist 体积:${formatSize(totalSize)}\r\n`);
process.stdout.write(`游戏已经输出至 dist 文件夹\r\n`);
process.stdout.write(`游戏已压缩至 dist.zip 压缩文件\r\n`);
}
// Execute
(() => {
buildGame();
})();
buildGame().catch(reason => {
process.stderr.write(String(reason));
process.exit(1);
});
-52
View File
@@ -1,52 +0,0 @@
import { build, loadConfigFromFile, mergeConfig, UserConfig } from 'vite';
import path from 'path';
import fs from 'fs-extra';
const packagesDir = path.resolve('./packages');
const outputDir = path.resolve('./dist/packages');
// 清空 dist 目录
fs.emptyDirSync(outputDir);
// 获取所有包目录
const packageDirs = fs.readdirSync(packagesDir).filter(name => {
const packagePath = path.join(packagesDir, name);
return fs.statSync(packagePath).isDirectory();
});
// 构建每一个包
async function buildPackages() {
for (const packageName of packageDirs) {
const packageDir = path.join(packagesDir, packageName);
const configFile = path.resolve('./vite.config.ts');
const config = await loadConfigFromFile(
{ command: 'build', mode: 'production' },
configFile
);
const resolved = mergeConfig(config?.config ?? {}, {
build: {
lib: {
entry: path.join(packageDir, 'src/index.ts'),
name: packageName,
formats: ['es'],
fileName: format => `${packageName}.${format}.js`
},
outDir: path.join(outputDir, packageName),
sourcemap: true,
emptyOutDir: true,
rollupOptions: {
external: [/node_modules/, /^@motajs\/.*/]
}
},
publicDir: false
} satisfies UserConfig);
await build(resolved);
console.log(`✅ Package ${packageName} built successfully.`);
}
}
buildPackages().catch(e => {
console.error(e);
process.exit(1);
});
+144
View File
@@ -0,0 +1,144 @@
import { build } from 'vite';
import { RollupOutput } from 'rollup';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { dataIsolationPlugin } from './build-plugin';
const tempDirs: string[] = [];
/** 创建独立的临时构建根目录,并登记以便用例结束后统一清理 */
async function createRoot() {
const root = await mkdtemp(join(tmpdir(), 'mota-data-isolation-'));
tempDirs.push(root);
return root;
}
/** 在临时根目录下写入文件,必要时创建父目录,返回写入后的绝对路径 */
async function writeRootFile(root: string, name: string, content: string) {
const file = join(root, name);
await mkdir(dirname(file), { recursive: true });
await writeFile(file, content, 'utf8');
return file;
}
/** 以数据端库模式 ESM 配置执行一次真实的 vite 构建 */
function buildFixture(root: string, entry: string) {
return build({
configFile: false,
root,
logLevel: 'silent',
build: {
write: false,
minify: false,
copyPublicDir: false,
lib: {
entry,
formats: ['es'],
fileName: 'data'
}
},
plugins: [dataIsolationPlugin()]
});
}
/** 执行构建并返回错误信息,构建成功时返回空字符串 */
async function getBuildError(root: string, entry: string) {
try {
await buildFixture(root, entry);
} catch (e) {
return String(e);
}
return '';
}
afterEach(async () => {
const dirs = tempDirs.splice(0);
await Promise.all(
dirs.map(dir => rm(dir, { recursive: true, force: true }))
);
});
describe('dataIsolationPlugin', () => {
// 直接引入 vue 说明符的数据端模块必须被插件拒绝
it('rejects a render-side specifier', async () => {
const root = await createRoot();
const entry = await writeRootFile(root, 'entry.ts', "import 'vue';\n");
const message = await getBuildError(root, entry);
expect(message).toContain('[mota-data-isolation]');
expect(message).toContain('matched: vue');
expect(message).toContain(
'data-side build must not depend on render-side'
);
});
// 相对引入的 src/client 渲染目录必须按解析路径被 moduleParsed 拒绝
it('rejects a resolved src/client module', async () => {
const root = await createRoot();
await writeRootFile(
root,
'src/client/ui/fake.ts',
'export const fake = 1;\n'
);
const entry = await writeRootFile(
root,
'entry.ts',
"import './src/client/ui/fake';\n"
);
const message = await getBuildError(root, entry);
expect(message).toContain('[mota-data-isolation]');
expect(message).toContain('matched: src/client');
});
// 相对引入的 packages-user/client-* 必须按解析路径被 moduleParsed 拒绝
it('rejects a resolved packages-user client package', async () => {
const root = await createRoot();
await writeRootFile(
root,
'packages-user/client-modules/fake.ts',
'export const fake = 1;\n'
);
const entry = await writeRootFile(
root,
'entry.ts',
"import './packages-user/client-modules/fake';\n"
);
const message = await getBuildError(root, entry);
expect(message).toContain('[mota-data-isolation]');
expect(message).toContain('matched: packages-user/client-');
});
// 纯数据端依赖必须通过构建并产出 ESM 代码
it('passes a pure data-side graph and emits ESM', async () => {
const root = await createRoot();
await writeRootFile(
root,
'packages-user/data-common/fake.ts',
'export const value = 1;\n'
);
const entry = await writeRootFile(
root,
'entry.ts',
"export { value } from './packages-user/data-common/fake';\n"
);
const result = (await buildFixture(root, entry)) as
| RollupOutput
| RollupOutput[];
const outputs = Array.isArray(result) ? result : [result];
const code = outputs
.flatMap(v => v.output)
.filter(v => v.type === 'chunk')
.map(v => v.code)
.join('\n');
expect(code).toContain('export');
});
});
+78
View File
@@ -0,0 +1,78 @@
import { Plugin } from 'vite';
import { ModuleInfo } from 'rollup';
/** 数据端构建中禁止出现的渲染端模块说明符,第一项为匹配规则,第二项为报错时的命中标记 */
const SPECIFIER_BANS: ReadonlyArray<readonly [RegExp, string]> = [
[/^@user\/client-/, '@user/client-'],
[/^@user\/entry-client(?:\/|$)/, '@user/entry-client'],
[/^@motajs\/render(?:[/-]|$)/, '@motajs/render'],
[/^@motajs\/client(?:[/-]|$)/, '@motajs/client'],
[/^@motajs\/system(?:[/-]|$)/, '@motajs/system'],
[/^@motajs\/audio(?:[/-]|$)/, '@motajs/audio'],
[/^@motajs\/legacy-/, '@motajs/legacy-'],
[/^vue(?:\/|$)/, 'vue'],
[/^@vue\//, '@vue/'],
[/^ant-design-vue(?:\/|$)/, 'ant-design-vue'],
[/^@ant-design\/icons-vue(?:\/|$)/, '@ant-design/icons-vue']
];
/** 数据端构建中禁止出现的渲染端模块解析路径,第一项为匹配规则,第二项为报错时的命中标记 */
const PATH_BANS: ReadonlyArray<readonly [RegExp, string]> = [
[/[/\\]packages-user[/\\]client-/, 'packages-user/client-'],
[/[/\\]packages[/\\]render(?:[/\\-]|$)/, 'packages/render'],
[/[/\\]packages[/\\]render-vue(?:[/\\-]|$)/, 'packages/render-vue'],
[/[/\\]packages[/\\]client(?:[/\\-]|$)/, 'packages/client'],
[/[/\\]packages[/\\]system(?:[/\\-]|$)/, 'packages/system'],
[/[/\\]packages[/\\]audio(?:[/\\-]|$)/, 'packages/audio'],
[/[/\\]packages[/\\]legacy-/, 'packages/legacy-'],
[/[/\\]src[/\\]client(?:[/\\]|$)/, 'src/client'],
[
/[/\\]node_modules[/\\](?:vue|@vue|ant-design-vue|@ant-design)(?:[/\\]|$)/,
'node_modules'
]
];
/**
* 在禁令集合中查找命中的规则
* @param bans 禁令集合
* @param value 待匹配的模块说明符或解析路径
*/
function findBan(
bans: ReadonlyArray<readonly [RegExp, string]>,
value: string
) {
return bans.find(ban => ban[0].test(value));
}
/**
* 创建数据端构建的隔离检查插件,禁止数据端模块图引用任何渲染端模块。
* 说明符经 `resolveId` 拦截,别名解析后的真实路径经 `moduleParsed` 兜底。
*/
export function dataIsolationPlugin(): Plugin {
return {
name: 'mota-data-isolation',
enforce: 'pre',
apply: 'build',
resolveId(source, importer) {
const ban = findBan(SPECIFIER_BANS, source);
if (ban) {
this.error(
`[mota-data-isolation] data-side build must not depend on render-side module "${source}" ` +
`(matched: ${ban[1]}). Importer: ${importer ?? '<entry>'}`
);
}
return null;
},
moduleParsed(info: ModuleInfo) {
const ban = findBan(PATH_BANS, info.id);
if (!ban) return;
const importers = this.getModuleInfo(info.id)!.importers;
this.error(
`[mota-data-isolation] data-side build must not depend on render-side module "${info.id}" ` +
`(matched: ${ban[1]}). Importers: ${
importers.length > 0 ? importers.join(', ') : '<entry>'
}`
);
}
};
}
+101 -205
View File
@@ -1,225 +1,121 @@
import JSZip from 'jszip';
import { RequiredData, RequiredIconsData } from './types';
import { Stats } from 'fs';
import { readdir, readFile, stat } from 'fs/promises';
import { resolve } from 'path';
import { fileHash } from './utils';
import Fontmin from 'fontmin';
import { copy } from 'fs-extra';
import { mkdir, readdir, readFile, writeFile } from 'fs/promises';
import { dirname, extname, join } from 'path';
export const enum CompressedUsage {
// ---- 系统加载内容,不可更改
Font,
Image,
Sound,
Tileset,
Autotile,
Material,
Animate
/** 需要经 fontmin 做字形子集化的字体扩展名 */
const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2'];
export interface IContentBuildOptions {
/** content 素材的源目录 */
readonly sourceDir: string;
/** content 素材的输出目录 */
readonly outputDir: string;
/** 参与字体子集化的全部字符 */
readonly chars: string;
}
export const enum LoadDataType {
ArrayBuffer,
Uint8Array,
Blob,
Text,
JSON
export interface IContentBuildStats {
/** 处理的文件总数 */
readonly files: number;
/** 压缩的 jsonc 文件数 */
readonly jsonc: number;
/** 压缩的字体文件数 */
readonly fonts: number;
}
export interface ResourceInfo {
readonly name: string;
readonly readAs: LoadDataType;
readonly usage: CompressedUsage;
readonly stats: Stats;
interface IContentBuildCounter {
files: number;
jsonc: number;
fonts: number;
}
export interface SplittedResource {
readonly byteLength: number;
readonly resource: JSZip;
readonly buffer: Uint8Array;
readonly fileName: string;
readonly content: Readonly<ResourceInfo>[];
/**
* 将 jsonc 文本压缩为单行纯 JSON,按行去掉全部 `//` 注释与系统标记行。
* @param text jsonc 原文
*/
export function minifyJSONC(text: string): string {
const body = text
.split(/\r?\n/)
.filter(line => !line.trim().startsWith('//'))
.join('\n');
return JSON.stringify(JSON.parse(body));
}
interface ResourceContent extends ResourceInfo {
readonly content: string | Buffer | Uint8Array;
readonly exceed: boolean;
/**
* 将单个字体文件按给定字符集做字形子集化,输出到目标目录并保留原文件名。
* @param source 字体源文件
* @param target 目标文件路径
* @param chars 参与子集化的全部字符
*/
async function compressFont(source: string, target: string, chars: string) {
const fontmin = new Fontmin();
const glyph = Fontmin.glyph({ text: chars });
await fontmin.src(source).dest(dirname(target)).use(glyph).runAsync();
}
interface ResourcePath {
readonly name: string;
readonly path: string;
readonly usage: CompressedUsage;
}
function getTypeByUsage(usage: CompressedUsage): LoadDataType {
switch (usage) {
case CompressedUsage.Animate:
return LoadDataType.Text;
case CompressedUsage.Autotile:
case CompressedUsage.Image:
case CompressedUsage.Tileset:
case CompressedUsage.Material:
return LoadDataType.Blob;
case CompressedUsage.Font:
case CompressedUsage.Sound:
return LoadDataType.ArrayBuffer;
}
}
function getZipFolderByUsage(usage: CompressedUsage): string {
switch (usage) {
case CompressedUsage.Image:
return 'image';
case CompressedUsage.Tileset:
return 'tileset';
case CompressedUsage.Autotile:
return 'autotile';
case CompressedUsage.Material:
return 'material';
case CompressedUsage.Font:
return 'font';
case CompressedUsage.Sound:
return 'sound';
case CompressedUsage.Animate:
return 'animate';
}
}
function readFileOfType(path: string, type: LoadDataType) {
if (type === LoadDataType.Text) {
return readFile(path, 'utf-8');
} else {
return readFile(path);
}
}
async function compressFiles(files: ResourceContent[]) {
const zip = new JSZip();
files.forEach(v => {
const dir = `${getZipFolderByUsage(v.usage)}/${v.name}`;
zip.file(dir, v.content);
});
const buffer = await zip.generateAsync({
type: 'uint8array',
compression: 'DEFLATE',
compressionOptions: {
level: 9
}
});
const hash = fileHash(buffer);
const name = `resource.${hash}.h5data`;
const resource: SplittedResource = {
byteLength: buffer.byteLength,
buffer: buffer,
resource: zip,
fileName: name,
content: files
};
return resource;
}
export async function splitResource(
data: RequiredData,
icons: RequiredIconsData,
base: string,
fontsDir: string,
limit: number
/**
* 递归处理目录:jsonc 压缩为纯 JSON,字体做子集化,其它素材原样拷贝。
* @param sourceDir 源目录
* @param outputDir 输出目录
* @param chars 参与字体子集化的全部字符
* @param counter 统计计数器
*/
async function processDirectory(
sourceDir: string,
outputDir: string,
chars: string,
counter: IContentBuildCounter
) {
const result: SplittedResource[] = [];
await mkdir(outputDir, { recursive: true });
const entries = await readdir(sourceDir, { withFileTypes: true });
// 获取所有需要分块的资源
const { animates, fonts, images, sounds, tilesets } = data.main;
const autotiles = Object.keys(icons.autotile);
const materials = await readdir(resolve(base, 'project/materials'));
for (const entry of entries) {
const source = join(sourceDir, entry.name);
const target = join(outputDir, entry.name);
const paths: ResourcePath[] = [
...animates.map<ResourcePath>(v => ({
name: `${v}.animate`,
path: resolve(base, 'project/animates', `${v}.animate`),
usage: CompressedUsage.Animate
})),
...fonts.map<ResourcePath>(v => ({
name: v,
path: resolve(fontsDir, v),
usage: CompressedUsage.Font
})),
...images.map<ResourcePath>(v => ({
name: v,
path: resolve(base, 'project/images', v),
usage: CompressedUsage.Image
})),
...sounds.map<ResourcePath>(v => ({
name: v,
path: resolve(base, 'project/sounds', v),
usage: CompressedUsage.Sound
})),
...tilesets.map<ResourcePath>(v => ({
name: v,
path: resolve(base, 'project/tilesets', v),
usage: CompressedUsage.Tileset
})),
...autotiles.map<ResourcePath>(v => ({
name: `${v}.png`,
path: resolve(base, 'project/autotiles', `${v}.png`),
usage: CompressedUsage.Autotile
})),
...materials.map<ResourcePath>(v => ({
name: v,
path: resolve(base, 'project/materials', v),
usage: CompressedUsage.Material
}))
];
if (entry.isDirectory()) {
await processDirectory(source, target, chars, counter);
continue;
}
const files = await Promise.all(
paths.map(async ({ path, usage, name }) => {
const stats = await stat(path);
if (!stats.isFile()) {
return Promise.reject(
new ReferenceError(
`Expected resource is a file, but get directory.`
)
);
}
const type = getTypeByUsage(usage);
const content = await readFileOfType(path, type);
const info: ResourceContent = {
readAs: type,
name,
usage,
stats,
content,
exceed: stats.size > limit
};
return info;
})
const extension = extname(entry.name);
if (extension === '.jsonc') {
const text = await readFile(source, 'utf-8');
await writeFile(target, minifyJSONC(text), 'utf-8');
counter.jsonc += 1;
} else if (FONT_EXTENSIONS.includes(extension)) {
await compressFont(source, target, chars);
counter.fonts += 1;
} else {
await copy(source, target);
}
counter.files += 1;
}
}
/**
* 将 content 素材从源目录构建到输出目录,jsonc 压缩为纯 JSON,字体做子集化。
* @param options 构建选项
*/
export async function buildContent(
options: IContentBuildOptions
): Promise<IContentBuildStats> {
const counter: IContentBuildCounter = { files: 0, jsonc: 0, fonts: 0 };
await processDirectory(
options.sourceDir,
options.outputDir,
options.chars,
counter
);
// 从小到大排序,这样的话可以尽量减小资源分块文件数量
files.sort((a, b) => a.stats.size - b.stats.size);
let index = 0;
while (index < files.length) {
let total = 0;
const start = index;
let i = index;
for (; i < files.length; i++) {
const file = files[i];
if (file.exceed) {
if (i === index) i = index + 1;
break;
} else {
total += file.stats.size;
}
if (total > limit) {
break;
}
}
index = i;
const toZip = files.slice(start, index);
result.push(await compressFiles(toZip));
if (counter.fonts === 0) {
process.stdout.write('⚠️ 未找到可压缩的字体文件,已跳过字体压缩\r\n');
}
return result;
return {
files: counter.files,
jsonc: counter.jsonc,
fonts: counter.fonts
};
}