minigame-std
Version:
Mini Game Standard Development Library.
714 lines (609 loc) • 22 kB
text/typescript
import type { FetchResponse, FetchTask } from '@happy-ts/fetch-t';
import { basename, dirname, join } from '@std/path/posix';
import * as fflate from 'fflate/browser';
import type { ExistsOptions, WriteOptions, ZipOptions } from 'happy-opfs';
import { Err, Ok, RESULT_VOID, type AsyncIOResult, type AsyncVoidIOResult, type IOResult, type VoidIOResult } from 'happy-rusty';
import { Future } from 'tiny-future';
import { assertSafeUrl } from '../assert/assertions.ts';
import { miniGameFailureToResult } from '../utils/mod.ts';
import type { DownloadFileOptions, ReadFileContent, ReadOptions, StatOptions, UploadFileOptions, WriteFileContent } from './fs_define.ts';
import { createAbortError } from './fs_helpers.ts';
import { errToMkdirResult, errToRemoveResult, fileErrorToResult, getAbsolutePath, getExistsResult, getFs, getReadFileEncoding, getRootUsrPath, getWriteFileContents, isNotFoundError } from './mina_fs_shared.ts';
/**
* 递归创建文件夹,相当于`mkdir -p`。
* @param dirPath - 需要创建的目录路径。
* @returns 创建结果的异步操作,成功时返回 true。
*/
export async function mkdir(dirPath: string): AsyncVoidIOResult {
const absPath = getAbsolutePath(dirPath);
// 根目录无需创建
if (absPath === getRootUsrPath()) {
return RESULT_VOID;
}
const statRes = await stat(absPath);
if (statRes.isOk()) {
// 存在则不创建
return RESULT_VOID;
}
const future = new Future<VoidIOResult>();
getFs().mkdir({
dirPath: absPath,
recursive: true,
success(): void {
future.resolve(RESULT_VOID);
},
fail(err): void {
future.resolve(errToMkdirResult(err));
},
});
return future.promise;
}
/**
* 重命名文件或目录。
* @param srcPath - 原路径。
* @param destPath - 新路径。
* @returns 重命名操作的异步结果,成功时返回 true。
*/
export function move(srcPath: string, destPath: string): AsyncVoidIOResult {
const absSrcPath = getAbsolutePath(srcPath);
const absDestPath = getAbsolutePath(destPath);
const future = new Future<VoidIOResult>();
getFs().rename({
oldPath: absSrcPath,
newPath: absDestPath,
success(): void {
future.resolve(RESULT_VOID);
},
fail(err): void {
future.resolve(fileErrorToResult(err));
},
});
return future.promise;
}
/**
* 读取目录下的所有文件和子目录。
* @param dirPath - 目录路径。
* @returns 包含目录内容的字符串数组的异步操作。
*/
export function readDir(dirPath: string): AsyncIOResult<string[]> {
const absPath = getAbsolutePath(dirPath);
const future = new Future<IOResult<string[]>>();
getFs().readdir({
dirPath: absPath,
success(res): void {
future.resolve(Ok(res.files));
},
fail(err): void {
future.resolve(fileErrorToResult(err));
},
});
return future.promise;
}
/**
* 以 UTF-8 格式读取文件。
* @param filePath - 文件路径。
* @param options - 读取选项,指定编码为 'utf8'。
* @returns 包含文件内容的字符串的异步操作。
*/
export function readFile(filePath: string, options: ReadOptions & {
encoding: 'utf8',
}): AsyncIOResult<string>;
/**
* 以二进制格式读取文件。
* @param filePath - 文件路径。
* @param options - 读取选项,指定编码为 'binary'。
* @returns 包含文件内容的 ArrayBuffer 的异步操作。
*/
export function readFile(filePath: string, options?: ReadOptions & {
encoding: 'binary',
}): AsyncIOResult<ArrayBuffer>;
/**
* 读取文件内容,可选地指定编码和返回类型。
* @template T - 返回内容的类型。
* @param filePath - 文件路径。
* @param options - 可选的读取选项。
* @returns 包含文件内容的异步操作。
*/
export function readFile<T extends ReadFileContent>(filePath: string, options?: ReadOptions): AsyncIOResult<T> {
const absPath = getAbsolutePath(filePath);
const encoding = getReadFileEncoding(options);
const future = new Future<IOResult<T>>();
getFs().readFile({
filePath: absPath,
encoding,
success(res): void {
future.resolve(Ok(res.data as T));
},
fail(err): void {
future.resolve(fileErrorToResult(err));
},
});
return future.promise;
}
/**
* 删除指定路径的文件或目录。
* @param path - 需要删除的文件或目录的路径。
* @returns 删除操作的异步结果,成功时返回 true。
*/
export async function remove(path: string): AsyncVoidIOResult {
const statRes = await stat(path);
if (statRes.isErr()) {
// 不存在当做成功
return isNotFoundError(statRes.unwrapErr()) ? RESULT_VOID : statRes.asErr();
}
const absPath = getAbsolutePath(path);
const future = new Future<VoidIOResult>();
// 文件夹还是文件
if (statRes.unwrap().isDirectory()) {
getFs().rmdir({
dirPath: absPath,
recursive: true,
success(): void {
future.resolve(RESULT_VOID);
},
fail(err): void {
future.resolve(errToRemoveResult(err));
},
});
} else {
getFs().unlink({
filePath: absPath,
success(): void {
future.resolve(RESULT_VOID);
},
fail(err): void {
future.resolve(errToRemoveResult(err));
},
});
}
return future.promise;
}
/**
* 获取文件或目录的状态信息。
* @param path - 文件或目录的路径。
* @param options - 可选选项。
* @returns 包含状态信息的异步操作。
*/
export function stat(path: string): AsyncIOResult<WechatMinigame.Stats>;
export function stat(path: string, options: StatOptions & {
recursive: true;
}): AsyncIOResult<WechatMinigame.FileStats[]>;
export function stat(path: string, options?: StatOptions): AsyncIOResult<WechatMinigame.Stats | WechatMinigame.FileStats[]>;
export function stat(path: string, options?: StatOptions): AsyncIOResult<WechatMinigame.Stats | WechatMinigame.FileStats[]> {
type T = WechatMinigame.Stats | WechatMinigame.FileStats[];
const absPath = getAbsolutePath(path);
const future = new Future<IOResult<T>>();
getFs().stat({
path: absPath,
recursive: options?.recursive ?? false,
success(res): void {
future.resolve(Ok(res.stats));
},
fail(err): void {
future.resolve(fileErrorToResult(err));
},
});
return future.promise;
}
/**
* 将内容写入文件。
* @param filePath - 文件路径。
* @param contents - 要写入的内容。
* @param options - 可选的写入选项。
* @returns 写入操作的异步结果,成功时返回 true。
*/
export async function writeFile(filePath: string, contents: WriteFileContent, options?: WriteOptions): AsyncVoidIOResult {
const absPath = getAbsolutePath(filePath);
// 默认创建
const { append = false, create = true } = options ?? {};
if (create) {
const res = await mkdir(dirname(absPath));
if (res.isErr()) {
return res;
}
}
const fs = getFs();
let method: typeof fs.appendFile | typeof fs.writeFile = fs.writeFile;
if (append) {
// append先判断文件是否存在
const res = await exists(absPath);
if (res.isErr()) {
return res.asErr();
}
if (res.unwrap()) {
// 文件存在才能使用appendFile
method = fs.appendFile;
}
}
const { data, encoding } = getWriteFileContents(contents);
const future = new Future<VoidIOResult>();
method({
filePath: absPath,
data,
encoding,
success(): void {
future.resolve(RESULT_VOID);
},
fail(err): void {
future.resolve(fileErrorToResult(err));
},
});
return future.promise;
}
/**
* 向文件追加内容。
* @param filePath - 文件路径。
* @param contents - 要追加的内容。
* @returns 追加操作的异步结果,成功时返回 true。
*/
export function appendFile(filePath: string, contents: WriteFileContent): AsyncVoidIOResult {
return writeFile(filePath, contents, {
append: true,
});
}
function copyFile(srcPath: string, destPath: string): AsyncVoidIOResult {
const future = new Future<VoidIOResult>();
getFs().copyFile({
srcPath,
destPath,
success(): void {
future.resolve(RESULT_VOID);
},
fail(err): void {
future.resolve(fileErrorToResult(err));
},
});
return future.promise;
}
/**
* 复制文件或文件夹。
*
* @param srcPath - 源文件或文件夹路径。
* @param destPath - 目标文件或文件夹路径。
* @returns 操作的异步结果。
*/
export async function copy(srcPath: string, destPath: string): AsyncVoidIOResult {
const absSrcPath = getAbsolutePath(srcPath);
const absDestPath = getAbsolutePath(destPath);
return (await stat(absSrcPath, {
recursive: true,
})).andThenAsync(async statsArray => {
// directory
if (Array.isArray(statsArray)) {
for (const { path, stats } of statsArray) {
// 不能用join
const srcEntryPath = absSrcPath + path;
const destEntryPath = absDestPath + path;
const res = await (stats.isDirectory()
? mkdir(destEntryPath)
: copyFile(srcEntryPath, destEntryPath));
if (res.isErr()) {
return res;
}
}
return RESULT_VOID;
} else {
// file
return (await mkdir(dirname(absDestPath))).andThenAsync(() => {
return copyFile(absSrcPath, absDestPath);
});
}
});
}
/**
* 检查指定路径的文件或目录是否存在。
* @param path - 文件或目录的路径。
* @param options - 可选的检查选项。
* @returns 检查存在性的异步结果,存在时返回 true。
*/
export async function exists(path: string, options?: ExistsOptions): AsyncIOResult<boolean> {
const res = await stat(path);
return getExistsResult(res, options);
}
/**
* 清空目录中的所有文件和子目录。
* @param dirPath - 目录路径。
* @returns 清空操作的异步结果,成功时返回 true。
*/
export async function emptyDir(dirPath: string): AsyncVoidIOResult {
const res = await readDir(dirPath);
if (res.isErr()) {
// 不存在则创建
return isNotFoundError(res.unwrapErr()) ? mkdir(dirPath) : res.asErr();
}
const tasks = res.unwrap().map(name => remove(join(dirPath, name)));
const allRes = await Promise.all(tasks);
// anyone failed?
const fail = allRes.find(x => x.isErr());
return fail ?? RESULT_VOID;
}
/**
* 读取文件并解析为 JSON。
* @param filePath - 文件路径。
* @returns 读取结果。
*/
export async function readJsonFile<T>(filePath: string): AsyncIOResult<T> {
return (await readTextFile(filePath)).andThenAsync(async contents => {
try {
return Ok(JSON.parse(contents));
} catch (e) {
return Err(e as Error);
}
});
}
/**
* 读取文本文件的内容。
* @param filePath - 文件路径。
* @returns 包含文件文本内容的异步操作。
*/
export function readTextFile(filePath: string): AsyncIOResult<string> {
return readFile(filePath, {
encoding: 'utf8',
});
}
/**
* 下载文件并保存到临时文件。
* @param fileUrl - 文件的网络 URL。
* @param options - 可选参数。
* @returns 下载操作的异步结果,成功时返回 true。
*/
export function downloadFile(fileUrl: string, options?: DownloadFileOptions): FetchTask<WechatMinigame.DownloadFileSuccessCallbackResult>;
/**
* 下载文件。
* @param fileUrl - 文件的网络 URL。
* @param filePath - 可选的下载后文件存储的路径,没传则存到临时文件。
* @param options - 可选参数。
* @returns 下载操作的异步结果,成功时返回 true。
*/
export function downloadFile(fileUrl: string, filePath: string, options?: DownloadFileOptions): FetchTask<WechatMinigame.DownloadFileSuccessCallbackResult>;
export function downloadFile(fileUrl: string, filePath?: string | DownloadFileOptions, options?: DownloadFileOptions): FetchTask<WechatMinigame.DownloadFileSuccessCallbackResult> {
type T = WechatMinigame.DownloadFileSuccessCallbackResult;
assertSafeUrl(fileUrl);
let absFilePath: string | undefined = undefined;
if (typeof filePath === 'string') {
absFilePath = getAbsolutePath(filePath);
} else {
options = filePath;
}
const {
onProgress,
...rest
} = options ?? {};
let aborted = false;
const future = new Future<IOResult<T>>();
let task: WechatMinigame.DownloadTask;
const download = () => {
task = wx.downloadFile({
...rest,
url: fileUrl,
filePath: absFilePath,
async success(res): Promise<void> {
if (aborted) {
future.resolve(Err(createAbortError()));
return;
}
const { statusCode } = res;
if (statusCode >= 200 && statusCode < 300) {
future.resolve(Ok(res));
return;
}
// remove the not expected file but no need to actively delete the temporary file
if (res.filePath) {
await remove(res.filePath);
}
future.resolve(Err(new Error(statusCode.toString())));
},
fail(err): void {
future.resolve(aborted ? Err(createAbortError()) : miniGameFailureToResult(err));
},
});
if (typeof onProgress === 'function') {
task.onProgressUpdate(res => {
const { totalBytesExpectedToWrite, totalBytesWritten } = res;
onProgress(typeof totalBytesExpectedToWrite === 'number' && typeof totalBytesWritten === 'number' ? Ok({
totalByteLength: totalBytesExpectedToWrite,
completedByteLength: totalBytesWritten,
}) : Err(new Error(`Unknown download progress ${ totalBytesWritten }/${ totalBytesExpectedToWrite }`)));
});
}
};
// maybe download to a temp file
if (typeof absFilePath === 'string' && absFilePath) {
// create the directory if not exists
mkdir(dirname(absFilePath)).then(res => {
if (aborted) {
future.resolve(Err(createAbortError()));
return;
}
if (res.isErr()) {
future.resolve(res.asErr());
return;
}
download();
});
} else {
download();
}
return {
abort(): void {
aborted = true;
task?.abort();
},
get aborted(): boolean {
return aborted;
},
get response(): FetchResponse<T> {
return future.promise;
},
};
}
/**
* 文件上传。
* @param filePath - 需要上传的文件路径。
* @param fileUrl - 目标网络 URL。
* @param options - 可选参数。
* @returns 上传操作的异步结果,成功时返回 true。
*/
export function uploadFile(filePath: string, fileUrl: string, options?: UploadFileOptions): FetchTask<WechatMinigame.UploadFileSuccessCallbackResult> {
type T = WechatMinigame.UploadFileSuccessCallbackResult;
assertSafeUrl(fileUrl);
const absPath = getAbsolutePath(filePath);
let aborted = false;
const future = new Future<IOResult<T>>();
const task = wx.uploadFile({
name: basename(filePath),
...options,
url: fileUrl,
filePath: absPath,
success(res): void {
future.resolve(Ok(res));
},
fail(err): void {
future.resolve(miniGameFailureToResult(err));
},
});
return {
abort(): void {
aborted = true;
task?.abort();
},
get aborted(): boolean {
return aborted;
},
get response(): FetchResponse<T> {
return future.promise;
},
};
}
/**
* 解压 zip 文件。
* @param zipFilePath - 要解压的 zip 文件路径。
* @param targetPath - 要解压到的目标文件夹路径。
* @returns 解压操作的异步结果。
*/
export function unzip(zipFilePath: string, targetPath: string): AsyncVoidIOResult {
const absZipPath = getAbsolutePath(zipFilePath);
const absTargetPath = getAbsolutePath(targetPath);
const future = new Future<VoidIOResult>();
getFs().unzip({
zipFilePath: absZipPath,
targetPath: absTargetPath,
success(): void {
future.resolve(RESULT_VOID);
},
fail(err): void {
future.resolve(fileErrorToResult(err));
},
});
return future.promise;
}
/**
* 从网络下载 zip 文件并解压。
* @param zipFileUrl - Zip 文件的网络地址。
* @param targetPath - 要解压到的目标文件夹路径。
* @param options - 可选的下载参数。
* @returns 下载并解压操作的异步结果。
*/
export async function unzipFromUrl(zipFileUrl: string, targetPath: string, options?: DownloadFileOptions): AsyncVoidIOResult {
return (await downloadFile(zipFileUrl, options).response).andThenAsync(({ tempFilePath }) => {
return unzip(tempFilePath, targetPath);
});
}
/**
* 压缩文件到内存。
* @param sourcePath - 需要压缩的文件(夹)路径。
* @param options - 可选的压缩参数。
* @returns 压缩成功的异步结果。
*/
export async function zip(sourcePath: string, options?: ZipOptions): AsyncIOResult<Uint8Array>;
/**
* 压缩文件。
* @param sourcePath - 需要压缩的文件(夹)路径。
* @param zipFilePath - 压缩后的 zip 文件路径。
* @param options - 可选的压缩参数。
* @returns 压缩成功的异步结果。
*/
export async function zip(sourcePath: string, zipFilePath: string, options?: ZipOptions): AsyncVoidIOResult
export async function zip<T>(sourcePath: string, zipFilePath?: string | ZipOptions, options?: ZipOptions): AsyncIOResult<T> {
const absSourcePath = getAbsolutePath(sourcePath);
let absZipFilePath: string;
if (typeof zipFilePath === 'string') {
absZipFilePath = getAbsolutePath(zipFilePath);
} else {
options = zipFilePath;
}
return (await stat(absSourcePath)).andThenAsync(async stats => {
const zipped: fflate.AsyncZippable = {};
const sourceName = basename(absSourcePath);
if (stats.isFile()) {
// file
const res = await readFile(absSourcePath);
if (res.isErr()) {
return res.asErr();
}
zipped[sourceName] = new Uint8Array(res.unwrap());
} else {
// directory
const res = await stat(absSourcePath, {
recursive: true,
});
if (res.isErr()) {
return res.asErr();
}
// default to preserve root
const preserveRoot = options?.preserveRoot ?? true;
for (const { path, stats } of res.unwrap()) {
if (stats.isFile()) {
const entryName = preserveRoot ? join(sourceName, path) : path;
// 不能用 join,否则 http://usr 会变成 http:/usr
const res = await readFile(absSourcePath + path);
if (res.isErr()) {
return res.asErr();
}
zipped[entryName] = new Uint8Array(res.unwrap());
}
}
}
const future = new Future<IOResult<T>>();
fflate.zip(zipped, {
consume: true,
}, async (err, u8a) => {
if (err) {
future.resolve(Err(err));
return;
}
if (absZipFilePath) {
const res = await writeFile(absZipFilePath, u8a);
future.resolve(res as IOResult<T>);
} else {
future.resolve(Ok(u8a as T));
}
});
return await future.promise;
});
}
type ZipFromUrlOptions = DownloadFileOptions & ZipOptions;
/**
* 下载文件并压缩到内存。
* @param sourceUrl - 要下载的文件 URL。
* @param options - 合并的下载和压缩选项。
*/
export async function zipFromUrl(sourceUrl: string, options?: ZipFromUrlOptions): AsyncIOResult<Uint8Array>;
/**
* 下载文件并压缩为 zip 文件。
* @param sourceUrl - 要下载的文件 URL。
* @param zipFilePath - 要输出的 zip 文件路径。
* @param options - 合并的下载和压缩选项。
*/
export async function zipFromUrl(sourceUrl: string, zipFilePath: string, options?: ZipFromUrlOptions): AsyncVoidIOResult;
export async function zipFromUrl<T>(sourceUrl: string, zipFilePath?: string | ZipFromUrlOptions, options?: ZipFromUrlOptions): AsyncIOResult<T> {
if (typeof zipFilePath !== 'string') {
options = zipFilePath;
zipFilePath = undefined;
}
return (await downloadFile(sourceUrl, options).response).andThenAsync(async ({ tempFilePath }) => {
return await (zipFilePath
? zip(tempFilePath, zipFilePath, options)
: zip(tempFilePath, options)) as IOResult<T>;
});
}