feat: 引入运行时日志体系和存储配置,配置文件改为必填
- 新增 pino/pino-pretty/pino-roll 依赖,实现结构化日志(console pretty + file JSONL rolling) - 新增 Logger 接口及 PinoLoggerWrapper/ConsoleFallbackLogger/NoopLogger/MemoryLogger 实现 - 新增 src/pino-roll.d.ts 类型声明 - 新增 server.storage.dataDir 配置(默认 ./data,相对路径基于配置文件目录) - 新增 server.logging 配置(level/console/file/rotation,支持变量引用) - 配置文件从可选改为必填,parseRuntimeArgs 无参数时抛错 - bootstrap 创建 logger、确保 dataDir、shutdown flush、失败路径 fallback - startServer 接收 logger 并输出结构化监听日志 - ESLint 新增 no-restricted-syntax 禁止 src/server 直接 console.*(排除 logger.ts) - 更新 config.example.yaml、README.md、DEVELOPMENT.md 同步配置和日志文档 - 完善测试覆盖:logger、config、schema、bootstrap 共 150 个测试通过
This commit is contained in:
11
src/pino-roll.d.ts
vendored
Normal file
11
src/pino-roll.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
declare module "pino-roll" {
|
||||
interface RollingStreamOptions {
|
||||
file: string;
|
||||
frequency?: string;
|
||||
limit?: { count?: number };
|
||||
mkdir?: boolean;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export default function build(options: RollingStreamOptions): Promise<NodeJS.WritableStream>;
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { ServerConfig } from "./config";
|
||||
import type { ResolvedConfig, ResolvedLoggingConfig } from "./config/types";
|
||||
import type { Logger } from "./logger";
|
||||
import type { StartServerOptions } from "./server";
|
||||
|
||||
import { loadServerConfig } from "./config";
|
||||
import { createConsoleFallback, createRuntimeLogger } from "./logger";
|
||||
import { startServer } from "./server";
|
||||
|
||||
export interface BootstrapDependencies {
|
||||
loadConfig?: (configPath?: string) => Promise<ServerConfig>;
|
||||
logError?: (...data: unknown[]) => void;
|
||||
createLogger?: (config: ResolvedLoggingConfig, mode: string, version?: string) => Promise<Logger>;
|
||||
exit?: (code: number) => never;
|
||||
loadConfig?: (configPath: string) => Promise<ResolvedConfig>;
|
||||
onSignal?: (signal: "SIGINT" | "SIGTERM", handler: () => void) => void;
|
||||
startServer?: (options: StartServerOptions) => unknown;
|
||||
}
|
||||
|
||||
export interface BootstrapOptions {
|
||||
config?: ServerConfig;
|
||||
configPath?: string;
|
||||
configPath: string;
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StartServerOptions["staticAssets"];
|
||||
version?: string;
|
||||
@@ -22,26 +26,61 @@ export interface BootstrapOptions {
|
||||
|
||||
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
|
||||
const load = dependencies.loadConfig ?? loadServerConfig;
|
||||
const buildLogger = dependencies.createLogger ?? createRuntimeLogger;
|
||||
const serve = dependencies.startServer ?? startServer;
|
||||
const onSignal =
|
||||
dependencies.onSignal ??
|
||||
((signal: "SIGINT" | "SIGTERM", handler: () => void) => {
|
||||
process.on(signal, handler);
|
||||
});
|
||||
const logError = dependencies.logError ?? console.error;
|
||||
const exit = dependencies.exit ?? ((code: number) => process.exit(code));
|
||||
|
||||
const createFallback = (): Logger => createConsoleFallback();
|
||||
|
||||
let logger: Logger | undefined;
|
||||
|
||||
try {
|
||||
const config = options.config ?? (await load(options.configPath));
|
||||
const config = await load(options.configPath);
|
||||
|
||||
try {
|
||||
logger = await buildLogger(config.logging, options.mode, options.version);
|
||||
} catch (logInitError) {
|
||||
createFallback().fatal(
|
||||
`日志初始化失败: ${logInitError instanceof Error ? logInitError.message : String(logInitError)}`,
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
logger!.info(
|
||||
{ configDir: config.configDir, configPath: options.configPath, mode: options.mode, version: options.version },
|
||||
"配置加载成功",
|
||||
);
|
||||
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
logger!.info({ dataDir: config.dataDir }, "数据目录就绪");
|
||||
|
||||
const shutdown = () => {
|
||||
process.exit(0);
|
||||
logger?.info("收到退出信号,开始优雅关闭");
|
||||
logger?.flush();
|
||||
exit(0);
|
||||
};
|
||||
onSignal("SIGINT", shutdown);
|
||||
onSignal("SIGTERM", shutdown);
|
||||
|
||||
serve({ config, mode: options.mode, staticAssets: options.staticAssets, version: options.version });
|
||||
serve({
|
||||
config: { host: config.host, port: config.port },
|
||||
logger: logger!.child({ component: "server" }),
|
||||
mode: options.mode,
|
||||
staticAssets: options.staticAssets,
|
||||
version: options.version,
|
||||
});
|
||||
} catch (error) {
|
||||
logError("启动失败:", error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
if (logger) {
|
||||
logger.fatal({ error: error instanceof Error ? error.message : String(error) }, "启动失败");
|
||||
logger.flush();
|
||||
} else {
|
||||
createFallback().fatal(`启动失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import { isNumber, isString } from "es-toolkit";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
|
||||
import type { ConfigValidationIssue } from "./config/issues";
|
||||
import type { LoggingConfig, LogLevel, ResolvedConfig, ResolvedLoggingConfig, RotationFrequency } from "./config/types";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
import { dedupeIssues, issue, throwConfigIssues } from "./config/issues";
|
||||
import { normalizeAuthoringConfig } from "./config/normalizer";
|
||||
import { validateConfigContract } from "./config/schema/validate";
|
||||
|
||||
export interface ServerConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 3000;
|
||||
const DEFAULT_DATA_DIR = "./data";
|
||||
const DEFAULT_LOG_LEVEL: LogLevel = "info";
|
||||
const DEFAULT_ROTATION_SIZE = "50MB";
|
||||
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
|
||||
const DEFAULT_ROTATION_MAX_FILES = 14;
|
||||
|
||||
export async function loadServerConfig(configPath?: string): Promise<ServerConfig> {
|
||||
if (!configPath) {
|
||||
return { host: DEFAULT_HOST, port: DEFAULT_PORT };
|
||||
}
|
||||
const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
||||
const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
|
||||
|
||||
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
|
||||
|
||||
export async function loadServerConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
const file = Bun.file(configPath);
|
||||
if (!(await file.exists())) {
|
||||
throw new Error(`配置文件不存在: ${configPath}`);
|
||||
@@ -43,33 +46,178 @@ export async function loadServerConfig(configPath?: string): Promise<ServerConfi
|
||||
const runtimeIssues = validateRuntimeConfig(contractResult.config);
|
||||
allIssues.push(...runtimeIssues);
|
||||
|
||||
const configDir = dirname(resolve(configPath));
|
||||
|
||||
const configRecord = contractResult.config as Record<string, unknown>;
|
||||
const server = configRecord["server"] as Record<string, unknown> | undefined;
|
||||
const listen = server?.["listen"] as Record<string, unknown> | undefined;
|
||||
const storage = server?.["storage"] as Record<string, unknown> | undefined;
|
||||
|
||||
const host = (listen?.["host"] as string | undefined) ?? DEFAULT_HOST;
|
||||
const port = (listen?.["port"] as number | undefined) ?? DEFAULT_PORT;
|
||||
const dataDir = resolveDataDir(storage, configDir);
|
||||
|
||||
const rawLogging = server?.["logging"] as LoggingConfig | undefined;
|
||||
const logging = resolveLogging(rawLogging ?? {}, dataDir, configDir);
|
||||
validateLoggingConfig(rawLogging, allIssues);
|
||||
|
||||
if (allIssues.length > 0) {
|
||||
throwConfigIssues(dedupeIssues(allIssues));
|
||||
}
|
||||
|
||||
return resolveServerConfig(contractResult.config);
|
||||
return { configDir, dataDir, host, logging, port };
|
||||
}
|
||||
|
||||
export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPath?: string } {
|
||||
if (argv.length === 0) return {};
|
||||
export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPath: string } {
|
||||
if (argv.length === 0) {
|
||||
throw new Error(`需要指定 YAML 配置文件路径\n用法: ${APP.name} <config.yaml>`);
|
||||
}
|
||||
const firstArg = argv[0];
|
||||
if (firstArg === "--help" || firstArg === "-h") {
|
||||
console.log(`用法: ${APP.name} [config.yaml]`);
|
||||
console.log(" config.yaml 可选 YAML 配置文件路径(不存在时使用默认配置)");
|
||||
process.exit(0);
|
||||
throw new Error(`用法: ${APP.name} <config.yaml>`);
|
||||
}
|
||||
return { configPath: firstArg };
|
||||
return { configPath: firstArg! };
|
||||
}
|
||||
|
||||
function resolveServerConfig(config: object): ServerConfig {
|
||||
const configRecord = config as Record<string, unknown>;
|
||||
const server = configRecord["server"] as Record<string, unknown> | undefined;
|
||||
const listen = server?.["listen"] as Record<string, unknown> | undefined;
|
||||
export function parseSize(value: number | string): number {
|
||||
if (isNumber(value)) {
|
||||
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {
|
||||
throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const host = (listen?.["host"] as string | undefined) ?? DEFAULT_HOST;
|
||||
const port = (listen?.["port"] as number | undefined) ?? DEFAULT_PORT;
|
||||
const match = SIZE_REGEX.exec(value);
|
||||
if (!match) {
|
||||
throw new Error(`无效的 size 格式: "${value}",支持格式如 "100MB"、"512KB"、"1GB"、"1024B"`);
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
const num = parseFloat(match[1]!);
|
||||
const unit = match[2]!;
|
||||
|
||||
const bytes =
|
||||
unit === "B" ? num : unit === "KB" ? num * 1024 : unit === "MB" ? num * 1024 * 1024 : num * 1024 * 1024 * 1024;
|
||||
if (!Number.isInteger(bytes) || bytes < 0 || !Number.isSafeInteger(bytes)) {
|
||||
throw new Error(`无效的 size 数值: ${value},必须解析为非负安全整数字节数`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function resolveDataDir(storage: Record<string, unknown> | undefined, configDir: string): string {
|
||||
const raw = storage?.["dataDir"];
|
||||
if (isString(raw) && raw.trim() !== "") {
|
||||
return isAbsolute(raw) ? resolve(raw) : resolve(configDir, raw);
|
||||
}
|
||||
return resolve(configDir, DEFAULT_DATA_DIR);
|
||||
}
|
||||
|
||||
function resolveLogging(logging: LoggingConfig, dataDir: string, configDir: string): ResolvedLoggingConfig {
|
||||
const globalLevel = resolveLogLevel(logging.level, DEFAULT_LOG_LEVEL);
|
||||
const consoleLevel = resolveLogLevel(logging.console?.level, globalLevel);
|
||||
const fileLevel = resolveLogLevel(logging.file?.level, globalLevel);
|
||||
|
||||
const rawPath = logging.file?.path;
|
||||
const filePath = rawPath
|
||||
? isAbsolute(rawPath)
|
||||
? resolve(rawPath)
|
||||
: resolve(configDir, rawPath)
|
||||
: resolve(dataDir, "logs", `${APP.name}.log`);
|
||||
|
||||
const rotationRaw = logging.file?.rotation;
|
||||
const rotationSizeRaw = rotationRaw?.size ?? DEFAULT_ROTATION_SIZE;
|
||||
const rotationSizeBytes = parseSize(rotationSizeRaw);
|
||||
const rotationFrequency = rotationRaw?.frequency ?? DEFAULT_ROTATION_FREQUENCY;
|
||||
const rotationMaxFiles = rotationRaw?.maxFiles ?? DEFAULT_ROTATION_MAX_FILES;
|
||||
|
||||
return {
|
||||
consoleLevel,
|
||||
fileLevel,
|
||||
filePath,
|
||||
rotationFrequency,
|
||||
rotationMaxFiles,
|
||||
rotationSizeBytes,
|
||||
rotationSizeRaw,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLogLevel(level: unknown, fallback: LogLevel): LogLevel {
|
||||
if (!isString(level)) return fallback;
|
||||
if (VALID_LOG_LEVELS.includes(level as LogLevel)) return level as LogLevel;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function validateLoggingConfig(logging: LoggingConfig | undefined, issues: ConfigValidationIssue[]): void {
|
||||
if (logging === undefined) return;
|
||||
|
||||
if (logging.level !== undefined && !VALID_LOG_LEVELS.includes(logging.level)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"server.logging.level",
|
||||
`日志等级非法: "${logging.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (logging.console?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.console.level)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"server.logging.console.level",
|
||||
`日志等级非法: "${logging.console.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (logging.file?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.file.level)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"server.logging.file.level",
|
||||
`日志等级非法: "${logging.file.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (logging.file?.path !== undefined) {
|
||||
if (!isString(logging.file.path) || logging.file.path.trim() === "") {
|
||||
issues.push(issue("invalid-value", "server.logging.file.path", "日志路径不能为空字符串或空白字符串"));
|
||||
}
|
||||
}
|
||||
|
||||
const rotation = logging.file?.rotation;
|
||||
if (rotation?.size !== undefined) {
|
||||
try {
|
||||
const bytes = parseSize(rotation.size);
|
||||
if (bytes <= 0) {
|
||||
issues.push(issue("invalid-value", "server.logging.file.rotation.size", "滚动大小必须为正整数字节数"));
|
||||
}
|
||||
} catch (error) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"server.logging.file.rotation.size",
|
||||
error instanceof Error ? error.message : "size 格式非法",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rotation?.frequency !== undefined && !VALID_ROTATION_FREQUENCIES.includes(rotation.frequency)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
"server.logging.file.rotation.frequency",
|
||||
`滚动频率非法: "${rotation.frequency}",支持: ${VALID_ROTATION_FREQUENCIES.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (rotation?.maxFiles !== undefined) {
|
||||
if (!isNumber(rotation.maxFiles) || !Number.isInteger(rotation.maxFiles) || rotation.maxFiles <= 0) {
|
||||
issues.push(issue("invalid-value", "server.logging.file.rotation.maxFiles", "maxFiles 必须为正整数"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntimeConfig(config: object): ConfigValidationIssue[] {
|
||||
|
||||
@@ -9,9 +9,19 @@ export { createConfigJsonSchema } from "./schema/export";
|
||||
export { createConfigAjv, issuesFromAjvErrors, validateConfigContract } from "./schema/validate";
|
||||
export type {
|
||||
AuthoringConfig,
|
||||
AuthoringLoggingConfig,
|
||||
AuthoringLoggingFileConfig,
|
||||
AuthoringLoggingFileRotationConfig,
|
||||
AuthoringServer,
|
||||
ConfigVariableValue,
|
||||
LoggingConfig,
|
||||
LogLevel,
|
||||
NormalizedConfig,
|
||||
NormalizedLoggingConfig,
|
||||
NormalizedServer,
|
||||
ResolvedConfig,
|
||||
ResolvedLoggingConfig,
|
||||
RotationFrequency,
|
||||
ValidatedConfig,
|
||||
} from "./types";
|
||||
export { extractVariables, resolveVariables } from "./variables";
|
||||
|
||||
@@ -6,6 +6,11 @@ import { variableValueSchema } from "./fragments";
|
||||
|
||||
type SchemaKind = "authoring" | "normalized";
|
||||
|
||||
const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
|
||||
const ROTATION_FREQUENCIES = ["hourly", "daily", "weekly"] as const;
|
||||
|
||||
const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]);
|
||||
|
||||
export function createAuthoringConfigSchema(): TSchema {
|
||||
return createConfigSchemaForKind("authoring");
|
||||
}
|
||||
@@ -42,6 +47,47 @@ function createConfigSchemaForKind(kind: SchemaKind): TSchema {
|
||||
return Type.Object(properties, { additionalProperties: false });
|
||||
}
|
||||
|
||||
function createLoggingSchema(kind: SchemaKind): TSchema {
|
||||
const logLevelSchema = Type.Union(LOG_LEVELS.map((l) => Type.Literal(l)) as unknown as [TSchema, ...TSchema[]]);
|
||||
const logLevel = kind === "authoring" ? createAuthoringFieldSchema(logLevelSchema) : logLevelSchema;
|
||||
const frequency =
|
||||
kind === "authoring"
|
||||
? createAuthoringFieldSchema(
|
||||
Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]),
|
||||
)
|
||||
: Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]);
|
||||
const rotationSize = kind === "authoring" ? createAuthoringFieldSchema(sizeSchema) : sizeSchema;
|
||||
const rotationMaxFiles =
|
||||
kind === "authoring" ? createAuthoringFieldSchema(Type.Integer({ minimum: 1 })) : Type.Integer({ minimum: 1 });
|
||||
|
||||
return Type.Object(
|
||||
{
|
||||
console: Type.Optional(Type.Object({ level: Type.Optional(logLevel) }, { additionalProperties: false })),
|
||||
file: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
level: Type.Optional(logLevel),
|
||||
path: Type.Optional(Type.String({ minLength: 1 })),
|
||||
rotation: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
frequency: Type.Optional(frequency),
|
||||
maxFiles: Type.Optional(rotationMaxFiles),
|
||||
size: Type.Optional(rotationSize),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
level: Type.Optional(logLevel),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
function createServerSchema(kind: SchemaKind): TSchema {
|
||||
return Type.Object(
|
||||
{
|
||||
@@ -54,6 +100,15 @@ function createServerSchema(kind: SchemaKind): TSchema {
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
logging: Type.Optional(createLoggingSchema(kind)),
|
||||
storage: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
dataDir: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -3,8 +3,32 @@ export interface AuthoringConfig {
|
||||
variables?: Record<string, ConfigVariableValue>;
|
||||
}
|
||||
|
||||
export interface AuthoringLoggingConfig {
|
||||
console?: AuthoringLoggingConsoleConfig;
|
||||
file?: AuthoringLoggingFileConfig;
|
||||
level?: string;
|
||||
}
|
||||
|
||||
export interface AuthoringLoggingConsoleConfig {
|
||||
level?: string;
|
||||
}
|
||||
|
||||
export interface AuthoringLoggingFileConfig {
|
||||
level?: string;
|
||||
path?: string;
|
||||
rotation?: AuthoringLoggingFileRotationConfig;
|
||||
}
|
||||
|
||||
export interface AuthoringLoggingFileRotationConfig {
|
||||
frequency?: string;
|
||||
maxFiles?: number | string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export interface AuthoringServer {
|
||||
listen?: AuthoringServerListen;
|
||||
logging?: AuthoringLoggingConfig;
|
||||
storage?: AuthoringServerStorage;
|
||||
}
|
||||
|
||||
export interface AuthoringServerListen {
|
||||
@@ -12,14 +36,58 @@ export interface AuthoringServerListen {
|
||||
port?: number | string;
|
||||
}
|
||||
|
||||
export interface AuthoringServerStorage {
|
||||
dataDir?: string;
|
||||
}
|
||||
|
||||
export type ConfigVariableValue = boolean | number | string;
|
||||
|
||||
export interface LoggingConfig {
|
||||
console?: { level?: LogLevel };
|
||||
file?: {
|
||||
level?: LogLevel;
|
||||
path?: string;
|
||||
rotation?: {
|
||||
frequency?: RotationFrequency;
|
||||
maxFiles?: number;
|
||||
size?: string;
|
||||
};
|
||||
};
|
||||
level?: LogLevel;
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn";
|
||||
|
||||
export interface NormalizedConfig {
|
||||
server?: NormalizedServer;
|
||||
}
|
||||
|
||||
export interface NormalizedLoggingConfig {
|
||||
console?: NormalizedLoggingConsoleConfig;
|
||||
file?: NormalizedLoggingFileConfig;
|
||||
level?: LogLevel;
|
||||
}
|
||||
|
||||
export interface NormalizedLoggingConsoleConfig {
|
||||
level?: LogLevel;
|
||||
}
|
||||
|
||||
export interface NormalizedLoggingFileConfig {
|
||||
level?: LogLevel;
|
||||
path?: string;
|
||||
rotation?: NormalizedLoggingFileRotationConfig;
|
||||
}
|
||||
|
||||
export interface NormalizedLoggingFileRotationConfig {
|
||||
frequency?: RotationFrequency;
|
||||
maxFiles?: number;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export interface NormalizedServer {
|
||||
listen?: NormalizedServerListen;
|
||||
logging?: NormalizedLoggingConfig;
|
||||
storage?: NormalizedServerStorage;
|
||||
}
|
||||
|
||||
export interface NormalizedServerListen {
|
||||
@@ -27,11 +95,30 @@ export interface NormalizedServerListen {
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface NormalizedServerStorage {
|
||||
dataDir?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedConfig {
|
||||
configDir: string;
|
||||
dataDir: string;
|
||||
host: string;
|
||||
logging: ResolvedLoggingConfig;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ResolvedLoggingConfig {
|
||||
consoleLevel: LogLevel;
|
||||
fileLevel: LogLevel;
|
||||
filePath: string;
|
||||
rotationFrequency: RotationFrequency;
|
||||
rotationMaxFiles: number;
|
||||
rotationSizeBytes: number;
|
||||
rotationSizeRaw: string;
|
||||
}
|
||||
|
||||
export type RotationFrequency = "daily" | "hourly" | "weekly";
|
||||
|
||||
export interface ValidatedConfig {
|
||||
server?: NormalizedServer;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { parseRuntimeArgs } from "./config";
|
||||
import { createConsoleFallback } from "./logger";
|
||||
|
||||
async function main() {
|
||||
const { configPath } = parseRuntimeArgs();
|
||||
@@ -7,6 +8,6 @@ async function main() {
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error("启动失败:", error instanceof Error ? error.message : error);
|
||||
createConsoleFallback().fatal(`启动失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
279
src/server/logger.ts
Normal file
279
src/server/logger.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import type pino from "pino";
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import type { LogLevel, ResolvedLoggingConfig } from "./config/types";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
|
||||
export interface Logger {
|
||||
child(bindings: Record<string, unknown>): Logger;
|
||||
debug(obj: Record<string, unknown>, msg?: string): void;
|
||||
debug(msg: string): void;
|
||||
error(obj: Record<string, unknown>, msg?: string): void;
|
||||
error(msg: string): void;
|
||||
fatal(obj: Record<string, unknown>, msg?: string): void;
|
||||
fatal(msg: string): void;
|
||||
flush(): void;
|
||||
info(obj: Record<string, unknown>, msg?: string): void;
|
||||
info(msg: string): void;
|
||||
trace(obj: Record<string, unknown>, msg?: string): void;
|
||||
trace(msg: string): void;
|
||||
warn(obj: Record<string, unknown>, msg?: string): void;
|
||||
warn(msg: string): void;
|
||||
}
|
||||
|
||||
export const REDACT_PATHS = [
|
||||
"authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"*.set-cookie",
|
||||
"authToken",
|
||||
"key",
|
||||
"password",
|
||||
"token",
|
||||
"apiKey",
|
||||
"*.authorization",
|
||||
"*.cookie",
|
||||
"*.authToken",
|
||||
"*.key",
|
||||
"*.password",
|
||||
"*.token",
|
||||
"*.apiKey",
|
||||
];
|
||||
|
||||
const LOG_LEVEL_MAP: Record<LogLevel, string> = {
|
||||
debug: "debug",
|
||||
error: "error",
|
||||
fatal: "fatal",
|
||||
info: "info",
|
||||
trace: "trace",
|
||||
warn: "warn",
|
||||
};
|
||||
|
||||
type LogFn = (objOrMsg: Record<string, unknown> | string, msg?: string) => void;
|
||||
|
||||
const voidLog: LogFn = () => undefined;
|
||||
|
||||
class ConsoleFallbackLogger implements Logger {
|
||||
child(_bindings: Record<string, unknown>): Logger {
|
||||
return this;
|
||||
}
|
||||
|
||||
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.log(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
|
||||
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.error(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
|
||||
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.error(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
|
||||
flush: () => void = () => undefined;
|
||||
|
||||
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.log(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
|
||||
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.log(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
|
||||
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
console.warn(formatMsg(objOrMsg, msg));
|
||||
}
|
||||
}
|
||||
|
||||
class NoopLogger implements Logger {
|
||||
debug: LogFn = voidLog;
|
||||
error: LogFn = voidLog;
|
||||
fatal: LogFn = voidLog;
|
||||
info: LogFn = voidLog;
|
||||
trace: LogFn = voidLog;
|
||||
warn: LogFn = voidLog;
|
||||
child(_bindings: Record<string, unknown>): Logger {
|
||||
return this;
|
||||
}
|
||||
flush: () => void = () => undefined;
|
||||
}
|
||||
|
||||
class PinoLoggerWrapper implements Logger {
|
||||
private pino: pino.Logger;
|
||||
|
||||
constructor(pinoLogger: pino.Logger) {
|
||||
this.pino = pinoLogger;
|
||||
}
|
||||
|
||||
child(bindings: Record<string, unknown>): Logger {
|
||||
return new PinoLoggerWrapper(this.pino.child(bindings));
|
||||
}
|
||||
|
||||
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.debug(objOrMsg);
|
||||
else this.pino.debug(objOrMsg, msg);
|
||||
}
|
||||
|
||||
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.error(objOrMsg);
|
||||
else this.pino.error(objOrMsg, msg);
|
||||
}
|
||||
|
||||
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.fatal(objOrMsg);
|
||||
else this.pino.fatal(objOrMsg, msg);
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
this.pino.flush();
|
||||
}
|
||||
|
||||
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.info(objOrMsg);
|
||||
else this.pino.info(objOrMsg, msg);
|
||||
}
|
||||
|
||||
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.trace(objOrMsg);
|
||||
else this.pino.trace(objOrMsg, msg);
|
||||
}
|
||||
|
||||
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") this.pino.warn(objOrMsg);
|
||||
else this.pino.warn(objOrMsg, msg);
|
||||
}
|
||||
}
|
||||
|
||||
export class MemoryLogger implements Logger {
|
||||
entries: Array<{ level: string; msg: string; obj?: Record<string, unknown> }> = [];
|
||||
|
||||
child(_bindings: Record<string, unknown>): Logger {
|
||||
return this;
|
||||
}
|
||||
|
||||
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("debug", objOrMsg, msg);
|
||||
}
|
||||
|
||||
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("error", objOrMsg, msg);
|
||||
}
|
||||
|
||||
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("fatal", objOrMsg, msg);
|
||||
}
|
||||
|
||||
flush: () => void = () => undefined;
|
||||
|
||||
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("info", objOrMsg, msg);
|
||||
}
|
||||
|
||||
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("trace", objOrMsg, msg);
|
||||
}
|
||||
|
||||
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
this.capture("warn", objOrMsg, msg);
|
||||
}
|
||||
|
||||
private capture(level: string, objOrMsg: Record<string, unknown> | string, msg?: string): void {
|
||||
if (typeof objOrMsg === "string") {
|
||||
this.entries.push({ level, msg: objOrMsg });
|
||||
} else {
|
||||
this.entries.push({ level, msg: msg ?? "", obj: objOrMsg });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createConsoleFallback(): Logger {
|
||||
return new ConsoleFallbackLogger();
|
||||
}
|
||||
|
||||
export function createMemoryLogger(): MemoryLogger {
|
||||
return new MemoryLogger();
|
||||
}
|
||||
|
||||
export function createNoopLogger(): Logger {
|
||||
return new NoopLogger();
|
||||
}
|
||||
|
||||
export async function createRuntimeLogger(
|
||||
config: ResolvedLoggingConfig,
|
||||
mode: string,
|
||||
version?: string,
|
||||
): Promise<Logger> {
|
||||
const pinoLib = await import("pino");
|
||||
const pinoPretty = await import("pino-pretty");
|
||||
|
||||
mkdirSync(dirname(config.filePath), { recursive: true });
|
||||
|
||||
const rootLevel = resolveRootLevel(config.consoleLevel, config.fileLevel);
|
||||
|
||||
const prettyStream = pinoPretty.default({
|
||||
colorize: true,
|
||||
ignore: "pid,hostname",
|
||||
singleLine: true,
|
||||
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l",
|
||||
});
|
||||
|
||||
const fileStream = await createRollingFileStream(config);
|
||||
|
||||
const streams: pino.StreamEntry[] = [
|
||||
{ level: toPinoLevel(config.consoleLevel) as pino.Level, stream: prettyStream },
|
||||
{ level: toPinoLevel(config.fileLevel) as pino.Level, stream: fileStream },
|
||||
];
|
||||
|
||||
const base: Record<string, unknown> = { mode, service: APP.name };
|
||||
if (version) base["version"] = version;
|
||||
|
||||
const logger = pinoLib.default(
|
||||
{
|
||||
base,
|
||||
level: rootLevel,
|
||||
redact: { censor: "[Redacted]", paths: REDACT_PATHS },
|
||||
timestamp: pinoLib.stdTimeFunctions.isoTime,
|
||||
},
|
||||
pinoLib.multistream(streams),
|
||||
);
|
||||
|
||||
return new PinoLoggerWrapper(logger);
|
||||
}
|
||||
|
||||
async function createRollingFileStream(config: ResolvedLoggingConfig): Promise<NodeJS.WritableStream> {
|
||||
const dir = dirname(config.filePath);
|
||||
const base = resolve(dir, config.filePath.replace(/^.*[\\/]/, "").replace(/\.log$/, ""));
|
||||
|
||||
try {
|
||||
const buildPinoRoll = (await import("pino-roll")).default;
|
||||
return await buildPinoRoll({
|
||||
file: base,
|
||||
frequency: config.rotationFrequency,
|
||||
limit: { count: config.rotationMaxFiles },
|
||||
mkdir: true,
|
||||
size: config.rotationSizeRaw,
|
||||
});
|
||||
} catch {
|
||||
const fs = await import("node:fs");
|
||||
return fs.createWriteStream(config.filePath, { flags: "a" });
|
||||
}
|
||||
}
|
||||
|
||||
function formatMsg(objOrMsg: Record<string, unknown> | string, msg?: string): string {
|
||||
if (typeof objOrMsg === "string") return objOrMsg;
|
||||
return msg ? `${msg} ${JSON.stringify(objOrMsg)}` : JSON.stringify(objOrMsg);
|
||||
}
|
||||
|
||||
function resolveRootLevel(consoleLevel: LogLevel, fileLevel: LogLevel): string {
|
||||
const order: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
||||
const ci = order.indexOf(consoleLevel);
|
||||
const fi = order.indexOf(fileLevel);
|
||||
return LOG_LEVEL_MAP[order[Math.min(ci, fi)]!] ?? "info";
|
||||
}
|
||||
|
||||
function toPinoLevel(level: LogLevel): string {
|
||||
return LOG_LEVEL_MAP[level];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { parseRuntimeArgs } from "./config";
|
||||
import { createConsoleFallback } from "./logger";
|
||||
|
||||
async function main() {
|
||||
const { configPath } = parseRuntimeArgs();
|
||||
@@ -7,6 +8,6 @@ async function main() {
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error("启动失败:", error instanceof Error ? error.message : error);
|
||||
createConsoleFallback().fatal(`启动失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { ServerConfig } from "./config";
|
||||
import type { Logger } from "./logger";
|
||||
import type { StaticAssets } from "./static";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
import { createApiError, jsonResponse } from "./helpers";
|
||||
import { handleMeta } from "./routes/meta";
|
||||
import { serveStaticAsset } from "./static";
|
||||
import { readAppVersion } from "./version";
|
||||
|
||||
export interface StartServerOptions {
|
||||
config: ServerConfig;
|
||||
config: { host: string; port: number };
|
||||
logger: Logger;
|
||||
mode: RuntimeMode;
|
||||
staticAssets?: StaticAssets;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function startServer(options: StartServerOptions) {
|
||||
const { config, mode, staticAssets, version } = options;
|
||||
const { config, logger, mode, staticAssets, version } = options;
|
||||
|
||||
const resolveVersion = (): Promise<string> => {
|
||||
if (version) return Promise.resolve(version);
|
||||
@@ -43,7 +43,7 @@ export function startServer(options: StartServerOptions) {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`${APP.name} listening on ${server.url}`);
|
||||
logger.info({ host: config.host, port: config.port, url: server.url.toString() }, "服务启动");
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user