UNPKG

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
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