feat: 初始提交

This commit is contained in:
2026-05-26 18:19:42 +08:00
commit 7ebf5ee5dc
107 changed files with 9317 additions and 0 deletions

86
src/server/bootstrap.ts Normal file
View File

@@ -0,0 +1,86 @@
import { mkdirSync } from "node:fs";
import type { RuntimeMode } from "../shared/api";
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 {
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 {
configPath: string;
mode: RuntimeMode;
staticAssets?: StartServerOptions["staticAssets"];
version?: string;
}
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 exit = dependencies.exit ?? ((code: number) => process.exit(code));
const createFallback = (): Logger => createConsoleFallback();
let logger: Logger | undefined;
try {
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 = () => {
logger?.info("收到退出信号,开始优雅关闭");
logger?.flush();
exit(0);
};
onSignal("SIGINT", shutdown);
onSignal("SIGTERM", shutdown);
serve({
config: { host: config.host, port: config.port },
logger: logger!.child({ component: "server" }),
mode: options.mode,
staticAssets: options.staticAssets,
version: options.version,
});
} catch (error) {
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);
}
}

241
src/server/config.ts Normal file
View File

@@ -0,0 +1,241 @@
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";
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;
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}`);
}
const content = await file.text();
const parsed = Bun.YAML.parse(content);
const normalizeResult = normalizeAuthoringConfig(parsed);
if (normalizeResult.issues.length > 0) {
throwConfigIssues(dedupeIssues(normalizeResult.issues));
}
const normalizedConfig = normalizeResult.config;
const contractResult = validateConfigContract(normalizedConfig);
if (contractResult.config === null) {
throwConfigIssues(dedupeIssues(contractResult.issues));
}
const allIssues: ConfigValidationIssue[] = [...contractResult.issues];
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 { configDir, dataDir, host, logging, port };
}
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") {
throw new Error(`用法: ${APP.name} <config.yaml>`);
}
return { configPath: firstArg! };
}
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 match = SIZE_REGEX.exec(value);
if (!match) {
throw new Error(`无效的 size 格式: "${value}",支持格式如 "100MB"、"512KB"、"1GB"、"1024B"`);
}
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[] {
const issues: ConfigValidationIssue[] = [];
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;
if (listen !== undefined) {
const portValue = listen["port"];
if (isString(portValue)) {
issues.push(
issue("invalid-type", "server.listen.port", "端口必须为整数,不能为字符串(如需使用变量请使用 ${VAR} 语法)"),
);
} else if (isNumber(portValue) && (!Number.isInteger(portValue) || portValue < 0 || portValue > 65535)) {
issues.push(issue("invalid-range", "server.listen.port", "端口必须为 0-65535 之间的整数"));
}
}
return issues;
}

View File

@@ -0,0 +1,27 @@
export { issue, joinPath, renderPath, throwConfigIssues } from "./issues";
export { normalizeAuthoringConfig } from "./normalizer";
export {
createAuthoringConfigSchema,
createExternalConfigSchema,
createNormalizedConfigSchema,
} from "./schema/builder";
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";

View File

@@ -0,0 +1,43 @@
export interface ConfigValidationIssue {
code: string;
message: string;
path: string;
}
export function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
const seen = new Set<string>();
const result: ConfigValidationIssue[] = [];
for (const item of issues) {
const key = `${item.code}:${item.path}:${item.message}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
export function formatConfigIssues(issues: ConfigValidationIssue[]): string {
return issues.map(formatConfigIssue).join("\n");
}
export function issue(code: string, path: string, message: string): ConfigValidationIssue {
return { code, message, path };
}
export function joinPath(base: string, key: string): string {
if (base === "") return key;
if (key.startsWith("[")) return `${base}${key}`;
return `${base}.${key}`;
}
export function renderPath(path: string): string {
return path === "" ? "配置文件" : path;
}
export function throwConfigIssues(issues: ConfigValidationIssue[]): never {
throw new Error(formatConfigIssues(issues));
}
function formatConfigIssue(i: ConfigValidationIssue): string {
return `${renderPath(i.path)} ${i.message}`;
}

View File

@@ -0,0 +1,18 @@
import { isPlainObject } from "es-toolkit";
import type { ConfigValidationIssue } from "./issues";
import { resolveVariables } from "./variables";
export function normalizeAuthoringConfig(config: unknown): {
config: unknown;
issues: ConfigValidationIssue[];
} {
const variableResult = resolveVariables(config);
if (!isPlainObject(variableResult.config)) {
return variableResult;
}
const normalized = { ...(variableResult.config as Record<string, unknown>) };
return { config: normalized, issues: variableResult.issues };
}

View File

@@ -0,0 +1,120 @@
import type { TSchema } from "@sinclair/typebox";
import { Type } from "@sinclair/typebox";
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");
}
export function createExternalConfigSchema(): Record<string, unknown> {
return {
...cloneSchema(createAuthoringConfigSchema()),
$id: "https://app.local/config.schema.json",
$schema: "http://json-schema.org/draft-07/schema#",
};
}
export function createNormalizedConfigSchema(): TSchema {
return createConfigSchemaForKind("normalized");
}
function cloneSchema(schema: TSchema): Record<string, unknown> {
return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
}
function createAuthoringFieldSchema(schema: TSchema): TSchema {
return Type.Unsafe({ anyOf: [schema, { pattern: "^\\$\\{[^}]+\\}$", type: "string" }] });
}
function createConfigSchemaForKind(kind: SchemaKind): TSchema {
const properties: Record<string, TSchema> = {
server: Type.Optional(createServerSchema(kind)),
};
if (kind === "authoring") {
properties["variables"] = Type.Optional(
Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema),
);
}
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(
{
listen: Type.Optional(
Type.Object(
{
host: Type.Optional(Type.String()),
port: Type.Optional(integerForKind(kind, { maximum: 65535, minimum: 0 })),
},
{ additionalProperties: false },
),
),
logging: Type.Optional(createLoggingSchema(kind)),
storage: Type.Optional(
Type.Object(
{
dataDir: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);
}
function integerForKind(kind: SchemaKind, options?: Parameters<typeof Type.Integer>[0]): TSchema {
const schema = Type.Integer(options);
return kind === "authoring" ? createAuthoringFieldSchema(schema) : schema;
}

View File

@@ -0,0 +1,5 @@
import { createExternalConfigSchema } from "./builder";
export function createConfigJsonSchema(): Record<string, unknown> {
return createExternalConfigSchema();
}

View File

@@ -0,0 +1,3 @@
import { Type } from "@sinclair/typebox";
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);

View File

@@ -0,0 +1,110 @@
import type { ErrorObject } from "ajv";
import Ajv from "ajv";
import type { ConfigValidationIssue } from "../issues";
import { issue } from "../issues";
import { createNormalizedConfigSchema } from "./builder";
export function createConfigAjv(): Ajv {
return new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false });
}
export function issuesFromAjvErrors(errors: ErrorObject[], root: unknown, basePath = ""): ConfigValidationIssue[] {
return normalizeAjvErrors(errors, basePath).map((error) => issueFromAjvError(error, root, basePath));
}
export function validateConfigContract(
config: unknown,
): { config: null; issues: ConfigValidationIssue[] } | { config: object; issues: [] } {
const ajv = createConfigAjv();
const rootValidate = ajv.compile(createNormalizedConfigSchema());
if (!rootValidate(config)) {
const issues = issuesFromAjvErrors(rootValidate.errors ?? [], config);
return { config: null, issues };
}
return { config: config as object, issues: [] as [] };
}
function buildIssuePath(basePath: string, error: ErrorObject): string {
const pointerPath = jsonPointerToPath(error.instancePath);
let path = basePath ? joinBasePath(basePath, pointerPath) : pointerPath;
if (error.keyword === "required" && "missingProperty" in error.params) {
path = joinBasePath(path, String(error.params["missingProperty"]));
}
if (error.keyword === "additionalProperties" && "additionalProperty" in error.params) {
path = joinBasePath(path, String(error.params["additionalProperty"]));
}
return path;
}
function hasMoreSpecificError(keywords: Set<string>): boolean {
return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword));
}
function issueFromAjvError(error: ErrorObject, _root: unknown, basePath: string): ConfigValidationIssue {
const path = buildIssuePath(basePath, error);
switch (error.keyword) {
case "additionalProperties":
return issue("unknown-field", path, "是未知字段");
case "const":
case "enum":
return issue("invalid-value", path, "不在允许范围内");
case "maximum":
case "minimum":
return issue("invalid-range", path, "数值范围不合法");
case "minLength":
return issue("invalid-format", path, "不能为空");
case "pattern":
return issue("invalid-format", path, "格式不合法");
case "required":
return issue("required", path, "缺少必填字段");
case "type":
return issue("invalid-type", path, "类型不合法");
default:
return issue("invalid-config", path, error.message ?? "配置不合法");
}
}
function joinBasePath(basePath: string, path: string): string {
if (basePath === "") return path;
if (path === "") return basePath;
if (path.startsWith("[")) return `${basePath}${path}`;
return `${basePath}.${path}`;
}
function jsonPointerToPath(pointer: string): string {
if (pointer === "") return "";
return pointer
.slice(1)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce((path, part) => (/^\d+$/.test(part) ? `${path}[${part}]` : joinBasePath(path, part)), "");
}
function normalizeAjvErrors(errors: ErrorObject[], basePath: string): ErrorObject[] {
const nonCompositeErrors = errors.filter((error) => error.keyword !== "anyOf" && error.keyword !== "oneOf");
const candidates = nonCompositeErrors.length > 0 ? nonCompositeErrors : errors;
const keywordsByPath = new Map<string, Set<string>>();
for (const error of candidates) {
const path = buildIssuePath(basePath, error);
const keywords = keywordsByPath.get(path) ?? new Set<string>();
keywords.add(error.keyword);
keywordsByPath.set(path, keywords);
}
const seenValueErrors = new Set<string>();
return candidates.filter((error) => {
const path = buildIssuePath(basePath, error);
const keywords = keywordsByPath.get(path) ?? new Set<string>();
if (error.keyword === "type" && hasMoreSpecificError(keywords)) return false;
if (error.keyword === "const" || error.keyword === "enum") {
if (seenValueErrors.has(path)) return false;
seenValueErrors.add(path);
}
return true;
});
}

124
src/server/config/types.ts Normal file
View File

@@ -0,0 +1,124 @@
export interface AuthoringConfig {
server?: AuthoringServer;
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 {
host?: string;
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 {
host?: string;
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;
}

View File

@@ -0,0 +1,188 @@
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "./issues";
import type { ConfigVariableValue } from "./types";
import { issue, joinPath } from "./issues";
const VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const VARIABLE_REFERENCE_PATTERN = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}/g;
const COMPLETE_VARIABLE_REFERENCE_PATTERN = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}$/;
const ESCAPED_VARIABLE_PATTERN = /\$\$\{([^}]*)\}/g;
interface VariableReference {
defaultValue?: string;
key: string;
}
interface VariableResolutionContext {
path: string;
}
export function extractVariables(config: unknown): {
issues: ConfigValidationIssue[];
variables: Map<string, ConfigVariableValue>;
} {
const issues: ConfigValidationIssue[] = [];
const variables = new Map<string, ConfigVariableValue>();
if (!isPlainObject(config)) {
return { issues, variables };
}
const configRecord = config as Record<string, unknown>;
if (configRecord["variables"] === undefined) {
return { issues, variables };
}
const rawVariables: unknown = configRecord["variables"];
if (!isPlainObject(rawVariables)) {
issues.push(issue("invalid-type", "variables", "必须为对象"));
return { issues, variables };
}
for (const [key, value] of Object.entries(rawVariables as Record<string, unknown>)) {
const path = joinPath("variables", key);
if (!VARIABLE_NAME_PATTERN.test(key)) {
issues.push(issue("invalid-format", path, "变量名不符合命名规则"));
continue;
}
if (!isVariableValue(value)) {
issues.push(issue("invalid-type", path, `变量值不允许为 ${describeInvalidVariableValue(value)}`));
continue;
}
variables.set(key, value);
}
return { issues, variables };
}
export function resolveVariables(config: unknown): { config: unknown; issues: ConfigValidationIssue[] } {
const { issues, variables } = extractVariables(config);
if (!isPlainObject(config)) {
return { config, issues };
}
return { config: resolveConfigValue(config, variables, issues), issues };
}
function describeInvalidVariableValue(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
return typeof value;
}
function inferStringValue(value: string): ConfigVariableValue {
if (value === "") return value;
const numberValue = Number(value);
if (Number.isFinite(numberValue)) return numberValue;
if (value === "true") return true;
if (value === "false") return false;
return value;
}
function isVariableValue(value: unknown): value is ConfigVariableValue {
return isString(value) || isNumber(value) || isBoolean(value);
}
function parseVariableReference(match: RegExpExecArray): VariableReference {
return { defaultValue: match[2], key: match[1]! };
}
function replaceStringValue(
value: string,
variables: Map<string, ConfigVariableValue>,
issues: ConfigValidationIssue[],
context: VariableResolutionContext,
): ConfigVariableValue | string {
const trimmed = value.trim();
const completeMatch = COMPLETE_VARIABLE_REFERENCE_PATTERN.exec(trimmed);
if (completeMatch) {
const resolved = resolveVariableReference(parseVariableReference(completeMatch), variables, issues, context);
return resolved ?? value;
}
const escaped: string[] = [];
const protectedValue = value.replace(ESCAPED_VARIABLE_PATTERN, (_match, body: string) => {
const token = `\u0000${escaped.length}\u0000`;
escaped.push(`\${${body}}`);
return token;
});
const replaced = protectedValue.replace(
VARIABLE_REFERENCE_PATTERN,
(match, key: string, defaultValue: string | undefined) => {
const resolved = resolveVariableReference({ defaultValue, key }, variables, issues, context);
return resolved === undefined ? match : String(resolved);
},
);
return escaped.reduce((result, literal, index) => result.replace(`\u0000${index}\u0000`, literal), replaced);
}
function resolveConfigValue(
value: unknown,
variables: Map<string, ConfigVariableValue>,
issues: ConfigValidationIssue[],
): unknown {
if (!isPlainObject(value)) return value;
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if (key === "variables") {
continue;
}
const itemPath = joinPath("", key);
result[key] = key === "server" ? resolveValue(item, itemPath, variables, issues) : item;
}
return result;
}
function resolveValue(
value: unknown,
path: string,
variables: Map<string, ConfigVariableValue>,
issues: ConfigValidationIssue[],
): unknown {
if (isString(value)) {
return replaceStringValue(value, variables, issues, { path });
}
if (Array.isArray(value)) {
return value.map((item, index) => resolveValue(item, `${path}[${index}]`, variables, issues));
}
if (!isPlainObject(value)) return value;
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
const itemPath = joinPath(path, key);
result[key] = resolveValue(item, itemPath, variables, issues);
}
return result;
}
function resolveVariableReference(
reference: VariableReference,
variables: Map<string, ConfigVariableValue>,
issues: ConfigValidationIssue[],
context: VariableResolutionContext,
): ConfigVariableValue | undefined {
if (variables.has(reference.key)) {
return variables.get(reference.key);
}
if (Object.prototype.hasOwnProperty.call(process.env, reference.key)) {
return inferStringValue(process.env[reference.key] ?? "");
}
if (reference.defaultValue !== undefined) {
return inferStringValue(reference.defaultValue);
}
issues.push(
issue(
"unresolved-variable",
context.path,
`引用了未定义的变量 "${reference.key}",且环境变量中也不存在,未设置默认值`,
),
);
return undefined;
}

13
src/server/dev.ts Normal file
View File

@@ -0,0 +1,13 @@
import { bootstrap } from "./bootstrap";
import { parseRuntimeArgs } from "./config";
import { createConsoleFallback } from "./logger";
async function main() {
const { configPath } = parseRuntimeArgs();
await bootstrap({ configPath, mode: "development" });
}
void main().catch((error) => {
createConsoleFallback().fatal(`启动失败: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});

48
src/server/helpers.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { ApiErrorResponse, MetaResponse, RuntimeMode } from "../shared/api";
import { APP } from "../shared/app";
export function createApiError(error: string, status: number): ApiErrorResponse {
return { error, status };
}
export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers {
const headers = new Headers(init);
if (mode === "production") {
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
}
return headers;
}
export function createMetaResponse(version: string): MetaResponse {
return {
ok: true,
service: APP.name,
timestamp: new Date().toISOString(),
version,
};
}
export function formatDuration(ms: number): string {
if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`;
if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`;
return `${ms}ms`;
}
export function jsonResponse(
body: unknown,
options: { headers?: HeadersInit; mode: RuntimeMode; status?: number },
): Response {
const headers = createHeaders(options.mode, {
"Content-Type": "application/json; charset=utf-8",
...options.headers,
});
return new Response(JSON.stringify(body), {
headers,
status: options.status,
});
}

279
src/server/logger.ts Normal file
View 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];
}

13
src/server/main.ts Normal file
View File

@@ -0,0 +1,13 @@
import { bootstrap } from "./bootstrap";
import { parseRuntimeArgs } from "./config";
import { createConsoleFallback } from "./logger";
async function main() {
const { configPath } = parseRuntimeArgs();
await bootstrap({ configPath, mode: "production" });
}
void main().catch((error) => {
createConsoleFallback().fatal(`启动失败: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});

63
src/server/middleware.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { RuntimeMode } from "../shared/api";
import { createApiError, jsonResponse } from "./helpers";
const MAX_PAGE_SIZE = 200;
export function validateIdParam(idStr: string, mode: RuntimeMode): Response | { id: string } {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(idStr)) {
return jsonResponse(createApiError("Invalid ID parameter", 400), { mode, status: 400 });
}
return { id: idStr };
}
export function validatePagination(
pageParam: null | string,
pageSizeParam: null | string,
mode: RuntimeMode,
): Response | { page: number; pageSize: number } {
let page = 1;
let pageSize = 20;
if (pageParam !== null) {
page = Number(pageParam);
if (!Number.isInteger(page) || page <= 0) {
return jsonResponse(createApiError("Invalid page parameter", 400), { mode, status: 400 });
}
}
if (pageSizeParam !== null) {
pageSize = Number(pageSizeParam);
if (!Number.isInteger(pageSize) || pageSize <= 0) {
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 });
}
if (pageSize > MAX_PAGE_SIZE) {
return jsonResponse(createApiError(`pageSize must not exceed ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 });
}
}
return { page, pageSize };
}
export function validateTimeRange(
from: null | string,
to: null | string,
mode: RuntimeMode,
): Response | { from: string; to: string } {
if (!from || !to) {
return jsonResponse(createApiError("from and to parameters are required", 400), { mode, status: 400 });
}
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { mode, status: 400 });
}
if (fromDate.getTime() > toDate.getTime()) {
return jsonResponse(createApiError("from must be earlier than to", 400), { mode, status: 400 });
}
return { from: fromDate.toISOString(), to: toDate.toISOString() };
}

View File

@@ -0,0 +1,7 @@
import type { RuntimeMode } from "../../shared/api";
import { createMetaResponse, jsonResponse } from "../helpers";
export function handleMeta(mode: RuntimeMode, version: string): Response {
return jsonResponse(createMetaResponse(version), { mode });
}

49
src/server/server.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { RuntimeMode } from "../shared/api";
import type { Logger } from "./logger";
import type { StaticAssets } from "./static";
import { createApiError, jsonResponse } from "./helpers";
import { handleMeta } from "./routes/meta";
import { serveStaticAsset } from "./static";
import { readAppVersion } from "./version";
export interface StartServerOptions {
config: { host: string; port: number };
logger: Logger;
mode: RuntimeMode;
staticAssets?: StaticAssets;
version?: string;
}
export function startServer(options: StartServerOptions) {
const { config, logger, mode, staticAssets, version } = options;
const resolveVersion = (): Promise<string> => {
if (version) return Promise.resolve(version);
return readAppVersion();
};
const server = Bun.serve({
fetch(req) {
if (staticAssets) {
return serveStaticAsset(new URL(req.url).pathname, staticAssets);
}
return new Response("Frontend is served by Vite dev server on :5173", { status: 404 });
},
hostname: config.host,
port: config.port,
routes: {
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
"/api/meta": {
GET: async () => {
const resolvedVersion = await resolveVersion();
return handleMeta(mode, resolvedVersion);
},
},
},
});
logger.info({ host: config.host, port: config.port, url: server.url.toString() }, "服务启动");
return server;
}

60
src/server/static.ts Normal file
View File

@@ -0,0 +1,60 @@
export interface StaticAssets {
files: Record<string, Blob>;
indexHtml: Blob;
}
const CONTENT_TYPES: Record<string, string> = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".woff": "font/woff",
".woff2": "font/woff2",
};
export function contentTypeFor(path: string): string {
const dot = path.lastIndexOf(".");
if (dot === -1) return "application/octet-stream";
const ext = path.slice(dot);
return CONTENT_TYPES[ext] ?? "application/octet-stream";
}
export function hasFileExtension(path: string): boolean {
const lastSlash = path.lastIndexOf("/");
const segment = lastSlash === -1 ? path : path.slice(lastSlash + 1);
return segment.includes(".");
}
export function htmlResponse(html: Blob): Response {
return new Response(html, {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/html; charset=utf-8",
},
});
}
export function serveStaticAsset(pathname: string, assets: StaticAssets): Response {
if (pathname === "/") {
return htmlResponse(assets.indexHtml);
}
const file = assets.files[pathname];
if (file) {
return new Response(file, {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": contentTypeFor(pathname),
},
});
}
if (hasFileExtension(pathname)) {
return new Response("Not found", { status: 404 });
}
return htmlResponse(assets.indexHtml);
}

17
src/server/version.ts Normal file
View File

@@ -0,0 +1,17 @@
import { resolve } from "node:path";
import { validateVersion } from "../../scripts/bump-version-logic";
const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "..", "package.json");
export async function readAppVersion(): Promise<string> {
const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string };
const version = packageJson.version;
if (typeof version !== "string") {
throw new Error("package.json does not have a valid version field");
}
validateVersion(version);
return version;
}