minigame-std
Version:
Cross-platform standard library for WeChat minigame and web browsers with unified APIs for crypto, fs, fetch, storage, and more.
547 lines (546 loc) • 16.2 kB
JavaScript
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
let happy_rusty = require("happy-rusty");
let fflate_browser = require("fflate/browser");
let src_std_codec_mod_ts = require("./codec.cjs");
let src_std_event_mod_ts = require("./event.cjs");
let src_std_fs_mod_ts = require("./fs.cjs");
//#region src/std/logger/helpers.ts
const LOG_LEVEL_INDEX = {
debug: 0,
info: 1,
warn: 2,
error: 3
};
/**
* 判断给定级别是否满足最低级别要求。
*
* @param entryLevel - 日志条目的级别。
* @param minLevel - 最低允许的级别。
* @returns `entryLevel >= minLevel` 时返回 `true`。
*/
function shouldLog(entryLevel, minLevel) {
return LOG_LEVEL_INDEX[entryLevel] >= LOG_LEVEL_INDEX[minLevel];
}
/**
* 将日志参数列表转为消息字符串。
*
* 单参数直接序列化,多参数用空格拼接,避免不必要的 map/join 开销。
*/
function buildMessage(args) {
return args.length === 1 ? stringifyArg(args[0]) : args.map(stringifyArg).join(" ");
}
/**
* 将单个参数转为字符串。
*
* string 原样返回;bigint 转 `` `${value}n` ``;object 尝试 JSON 序列化
*(含 BigInt replacer fallback);其余用 String()。
*/
function stringifyArg(arg) {
if (typeof arg === "bigint") return `${arg}n`;
if (typeof arg === "object" && arg !== null) try {
return JSON.stringify(arg, (_, v) => typeof v === "bigint" ? `${v}n` : v);
} catch {
return String(arg);
}
return String(arg);
}
//#endregion
//#region src/std/logger/logger_core.ts
/**
* 日志系统核心逻辑:单例状态管理、日志流水线、插件调度。
*/
/**
* Console 方法映射。
*
* `.bind(console)` 防止直接传递方法引用时 `this` 上下文丢失。
*/
const CONSOLE_FN = {
"debug": console.debug.bind(console),
"info": console.info.bind(console),
"warn": console.warn.bind(console),
"error": console.error.bind(console)
};
/**
* 全局单例状态(懒初始化)。
*/
let lazyState = /*#__PURE__*/ (0, happy_rusty.Lazy)(() => createDefaultState());
/**
* 初始化日志系统。
*
* @param config - 日志系统配置。
* @since 2.6.0
* @example
* ```ts
* const file = fileLog({ level: 'debug' });
* logger.init({ plugins: [file, wxLog({ level: 'warn' })] });
* ```
*/
function init(config = {}) {
lazyState.get().inspect((state) => {
for (const plugin of state.plugins) plugin.onDestroy?.();
});
const level = config.level ?? "info";
const plugins = config.plugins ?? [];
const ctx = {
globalLevel: level,
filter: config.filter
};
for (const plugin of plugins) plugin.onInit?.(ctx);
const newState = {
console: {
enabled: config.console?.enabled ?? true,
level: config.console?.level ?? level,
filter: config.filter
},
plugins
};
lazyState = (0, happy_rusty.Lazy)(() => newState);
if (config.injectConsole) injectConsole();
}
/**
* 输出 debug 级别日志。
* @since 2.6.0
*/
function debug(...args) {
dispatchLog("debug", ...args);
}
/**
* 输出 info 级别日志。
* @since 2.6.0
*/
function info(...args) {
dispatchLog("info", ...args);
}
/**
* 输出 warn 级别日志。
* @since 2.6.0
*/
function warn(...args) {
dispatchLog("warn", ...args);
}
/**
* 输出 error 级别日志。
* @since 2.6.0
*/
function error(...args) {
dispatchLog("error", ...args);
}
/**
* 拦截全局 `console` 方法,将其重定向到 logger。
*
* 调用后,`console.debug`/`info`/`warn`/`error`(以及 `console.log`)会经过
* logger 的 `dispatchLog`,触发插件 pipeline(如 `fileLog`)。
*
* logger 自身的 console 输出使用模块加载时捕获的原始方法(`CONSOLE_FN`),
* 不会递归。
*
* @returns restore 函数,调用后恢复原始 `console` 方法。
* @since 2.6.0
* @example
* ```ts
* logger.init({ plugins: [fileLog()] });
* const restore = injectConsole();
* // 之后所有 console.info(...) 会走 logger pipeline
* console.info('App started'); // → file 写入 + console 输出
* restore(); // 需要时恢复
* ```
*/
function injectConsole() {
const original = {
log: console.log,
debug: console.debug,
info: console.info,
warn: console.warn,
error: console.error
};
console.log = (...args) => info(...args);
console.debug = (...args) => debug(...args);
console.info = (...args) => info(...args);
console.warn = (...args) => warn(...args);
console.error = (...args) => error(...args);
return () => {
console.log = original.log;
console.debug = original.debug;
console.info = original.info;
console.warn = original.warn;
console.error = original.error;
};
}
function createDefaultState() {
return {
console: {
enabled: true,
level: "info"
},
plugins: []
};
}
/**
* 统一的日志分发入口。
*
* 控制台和插件均接收原始参数,由各消费端自行决定如何格式化或过滤。
*/
function dispatchLog(level, ...args) {
const state = lazyState.force();
if (state.console.enabled && shouldLog(level, state.console.level) && (!state.console.filter || state.console.filter(level, ...args))) CONSOLE_FN[level](...args);
for (const plugin of state.plugins) plugin.onLog?.(level, ...args);
}
//#endregion
//#region src/std/logger/plugins/file_logger.ts
/**
* 文件日志插件:fileLog 工厂,提供缓冲写入、日志分割(period + size)、旧文件清理。
*/
/**
* 默认日志文件根目录。
*/
const DEFAULT_ROOT_DIR = "/.minigame-std-logs";
/**
* 日志级别标签。
*/
const LEVEL_LABELS = {
debug: "DEBUG",
info: "INFO ",
warn: "WARN ",
error: "ERROR"
};
/**
* 创建文件日志插件。
*
* 插件在创建时即完成初始化(恢复/创建活跃文件、启动定时 flush、注册切后台监听),
* 因此即使不传给 `logger.init()` 也能独立工作。传给 `logger.init()` 后,
* `onInit` 会用全局配置(`level`/`filter`)refinement 自身配置。
*
* **注意**:`onInit`/`onLog` 由 logger 核心调度,不应手动调用。
*
* @param config - 文件插件配置。
* @returns 支持 LoggerPlugin 和文件管理 API 的插件实例。
* @since 2.6.0
* @example
* ```ts
* const file = fileLog({ level: 'debug' });
* logger.init({ plugins: [file] });
* await file.flush();
* ```
*/
function fileLog(config = {}) {
const rootDir = config.rootDir ?? DEFAULT_ROOT_DIR;
const split = {
period: config.split?.period ?? 36e5,
maxSize: config.split?.maxSize ?? 10 * 1024 * 1024,
maxCount: config.split?.maxCount ?? 24,
maxAge: config.split?.maxAge,
useByteSize: config.split?.useByteSize ?? false,
compress: config.split?.compress ?? false
};
const maxBufferSize = config.maxBufferSize ?? 100;
const flushInterval = config.flushInterval ?? 5e3;
const buffer = [];
let level = config.level ?? "info";
let filter = config.filter;
const formatter = config.formatter ?? defaultFormatter;
let currentFile = newLogPath();
let currentFileSize = 0;
let currentFileStartTime = Date.now();
let flushTimer = null;
let hideListenerRemover = null;
const pending = [];
let initPromise = null;
const inFlight = /* @__PURE__ */ new Set();
let prunePromise = null;
function periodIndex(ts) {
return Math.floor(ts / split.period);
}
function inCurrentPeriod(ts) {
return periodIndex(ts) >= periodIndex(Date.now());
}
function isFileExpired() {
return !inCurrentPeriod(currentFileStartTime);
}
function newLogPath() {
return `${rootDir}/${toLogName()}`;
}
function newFile() {
if (split.compress && currentFileSize > 0) compressOldFile(currentFile, inFlight).catch(() => {});
currentFile = newLogPath();
currentFileStartTime = Date.now();
currentFileSize = 0;
schedulePrune();
}
function flushCurrent() {
if (buffer.length === 0) return;
const content = buffer.splice(0).map((e) => e.formatted).join("");
const p = (0, src_std_fs_mod_ts.appendFile)(currentFile, content).then(() => {}).finally(() => {
inFlight.delete(p);
}).catch(() => {});
inFlight.add(p);
if (flushTimer != null) {
clearInterval(flushTimer);
flushTimer = setInterval(flushCurrent, flushInterval);
}
}
function schedulePrune() {
const p = (prunePromise ?? Promise.resolve()).then(() => Promise.all(inFlight)).then(tryReadLogFiles).then(pruneOldFiles).catch(() => {}).finally(() => {
if (prunePromise === p) prunePromise = null;
});
prunePromise = p;
return p;
}
async function readLogFiles() {
return (await (0, src_std_fs_mod_ts.readDir)(rootDir)).map((files) => files.filter((n) => n.endsWith(".log") || n.endsWith(".log.gz")).sort());
}
async function tryReadLogFiles() {
return (await readLogFiles()).unwrapOr([]);
}
async function pruneOldFiles(logFiles) {
if (logFiles.length === 0) return;
const { maxAge, maxCount } = split;
const currentFileName = currentFile.substring(rootDir.length + 1);
const effectiveMaxCount = !logFiles.includes(currentFileName) ? maxCount - 1 : maxCount;
if (maxAge == null && logFiles.length <= effectiveMaxCount) return;
const now = Date.now();
const toDelete = [];
let remaining;
if (maxAge != null) {
remaining = [];
for (const name of logFiles) {
const ts = parseTimestamp(name);
if (ts != null && now - ts > maxAge) toDelete.push(name);
else remaining.push(name);
}
} else remaining = logFiles;
if (remaining.length > effectiveMaxCount) {
const excess = remaining.slice(0, remaining.length - effectiveMaxCount);
toDelete.push(...excess);
}
if (toDelete.length === 0) return;
await Promise.all(toDelete.map((name) => (0, src_std_fs_mod_ts.remove)(`${rootDir}/${name}`)));
}
function writeEntry(level, args, timestamp = Date.now()) {
const entry = {
timestamp,
level,
message: buildMessage(args)
};
const formatted = formatter(entry);
const entryLen = split.useByteSize ? (0, src_std_codec_mod_ts.encodeUtf8)(formatted).length : formatted.length;
if (isFileExpired() || currentFileSize + entryLen > split.maxSize) {
flushCurrent();
newFile();
}
buffer.push({
entry,
formatted
});
currentFileSize += entryLen;
if (buffer.length >= maxBufferSize) flushCurrent();
}
function startAutoFlush() {
if (flushInterval > 0) flushTimer = setInterval(flushCurrent, flushInterval);
}
async function resumeOrCreate() {
const logFiles = await tryReadLogFiles();
const resumable = logFiles.filter((n) => n.endsWith(".log"));
if (resumable.length === 0) newFile();
else {
const latestPath = `${rootDir}/${resumable[resumable.length - 1]}`;
const statResult = await (0, src_std_fs_mod_ts.stat)(latestPath);
if (statResult.isOkAnd((x) => inCurrentPeriod(x.lastModifiedTime))) {
const s = statResult.unwrap();
currentFile = latestPath;
currentFileStartTime = s.lastModifiedTime;
currentFileSize = s.size;
} else newFile();
}
await pruneOldFiles(logFiles);
}
initPromise = resumeOrCreate().finally(() => {
startAutoFlush();
hideListenerRemover = (0, src_std_event_mod_ts.addHideListener)(flushCurrent);
for (const [lvl, args, ts] of pending) writeEntry(lvl, args, ts);
pending.length = 0;
initPromise = null;
}).catch(() => {});
return {
name: "fileLog",
onInit(ctx) {
level = config.level ?? ctx.globalLevel;
filter = config.filter !== void 0 ? config.filter : ctx.filter;
},
onLog(lvl, ...args) {
if (!shouldLog(lvl, level)) return;
if (filter && !filter(lvl, ...args)) return;
if (initPromise != null) {
pending.push([
lvl,
args,
Date.now()
]);
return;
}
writeEntry(lvl, args);
},
onDestroy() {
if (flushTimer != null) {
clearInterval(flushTimer);
flushTimer = null;
}
hideListenerRemover?.();
hideListenerRemover = null;
},
async flush() {
if (initPromise != null) await initPromise;
flushCurrent();
await schedulePrune();
},
async getFiles(query) {
flushCurrent();
await Promise.all(inFlight);
return (await readLogFiles()).map((logFiles) => {
const from = query?.from;
const to = query?.to;
const needFilter = from != null || to != null;
const files = [];
for (const name of logFiles) {
if (needFilter) {
const fileTimestamp = parseTimestamp(name);
if (fileTimestamp != null) {
if (from != null && fileTimestamp < from) continue;
if (to != null && fileTimestamp > to) continue;
}
}
files.push(`${rootDir}/${name}`);
}
return files;
});
},
getRootDir() {
return rootDir;
}
};
}
/**
* 默认日志格式化器。
*
* 输出格式:`[2026-06-18 14:30:00.123] [INFO ] 消息内容\n`
*/
function defaultFormatter(entry) {
return `[${formatTimestamp(entry.timestamp, "yyyy-MM-dd HH:mm:ss.SSS")}] [${LEVEL_LABELS[entry.level]}] ${entry.message}\n`;
}
/**
* 格式化时间戳为指定模式。
*/
function formatTimestamp(timestamp, pattern) {
const date = new Date(timestamp);
const tokens = {
"yyyy": date.getFullYear().toString(),
"MM": pad2(date.getMonth() + 1),
"dd": pad2(date.getDate()),
"HH": pad2(date.getHours()),
"mm": pad2(date.getMinutes()),
"ss": pad2(date.getSeconds()),
"SSS": pad3(date.getMilliseconds())
};
return pattern.replace(/yyyy|MM|dd|HH|mm|ss|SSS/g, (matched) => tokens[matched]);
}
/**
* 用当前时间戳生成独一无二的日志文件名(本地时间)。
*
* 全部使用 `-` 分隔,避免 ISO 8601 的 `T` 误导(文件名是本地时间,无时区后缀)。
* 排序友好:`localeCompare` 按年-月-日-时-分-秒.毫秒逐位递减排序。
*
* 跨时区场景下应以 `LogEntry.timestamp`(epoch 毫秒)为准。
*/
function toLogName() {
return `${formatTimestamp(Date.now(), "yyyy-MM-dd-HH-mm-ss.SSS")}.log`;
}
/**
* 从日志文件名解析时间戳(按本地时间)。
*
* 文件名由 {@link toLogName} 生成:`yyyy-MM-dd-HH-mm-ss.SSS.log`。
* 用 `new Date(y, mo-1, d, h, mi, s, ms)` 显式按本地时间构造,避免
* `new Date(string)` 在不同运行时对无时区字符串的解析差异。
*/
function parseTimestamp(name) {
const match = name.match(/^(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})\.(\d{3})\.log(?:\.gz)?$/);
if (!match) return null;
const [, y, mo, d, h, mi, s, ms] = match.map(Number);
return new Date(y, mo - 1, d, h, mi, s, ms).getTime();
}
function pad2(n) {
return pad(n, 2);
}
function pad3(n) {
return pad(n, 3);
}
function pad(n, maxLength) {
return n.toString().padStart(maxLength, "0");
}
/**
* 压缩旧日志文件:等待 in-flight append 完成 → 读 → gzipSync → 写 .log.gz → 删 .log
*/
async function compressOldFile(filePath, pending) {
await Promise.all(pending);
const readResult = await (0, src_std_fs_mod_ts.readFile)(filePath);
if (readResult.isErr()) return;
const compressed = (0, fflate_browser.gzipSync)(readResult.unwrap());
if ((await (0, src_std_fs_mod_ts.writeFile)(`${filePath}.gz`, compressed)).isErr()) return;
await (0, src_std_fs_mod_ts.remove)(filePath);
}
//#endregion
//#region src/std/logger/plugins/mina_logger.ts
/**
* wx.getLogManager 插件:声明式创建 wxLog。
*/
/**
* 创建 wx.getLogManager 插件(仅小游戏生效)。
*
* @param config - 插件配置。
* @returns LoggerPlugin 实例。
* @since 2.6.0
* @example
* ```ts
* logger.init({ plugins: [wxLog({ level: 'warn' })] });
* ```
*/
function wxLog(config = {}) {
const logManager = (0, happy_rusty.Lazy)(() => wx.getLogManager({ level: config.rawLevel ?? 0 }));
let level;
let filter;
return {
name: "wxLog",
onInit(ctx) {
level = config.level ?? ctx.globalLevel;
filter = config.filter !== void 0 ? config.filter : ctx.filter;
},
onLog(lvl, ...args) {
if (!shouldLog(lvl, level)) return;
if (filter && !filter(lvl, ...args)) return;
const manager = logManager.force();
const message = buildMessage(args);
switch (lvl) {
case "debug":
manager.debug(message);
break;
case "info":
manager.info(message);
break;
case "warn":
manager.warn(message);
break;
case "error":
manager.warn(`[ERROR] ${message}`);
break;
}
}
};
}
//#endregion
exports.debug = debug;
exports.error = error;
exports.fileLog = fileLog;
exports.info = info;
exports.init = init;
exports.injectConsole = injectConsole;
exports.warn = warn;
exports.wxLog = wxLog;
//# sourceMappingURL=logger.cjs.map