feat: 初始提交
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>;
|
||||
}
|
||||
86
src/server/bootstrap.ts
Normal file
86
src/server/bootstrap.ts
Normal 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
241
src/server/config.ts
Normal 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;
|
||||
}
|
||||
27
src/server/config/index.ts
Normal file
27
src/server/config/index.ts
Normal 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";
|
||||
43
src/server/config/issues.ts
Normal file
43
src/server/config/issues.ts
Normal 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}`;
|
||||
}
|
||||
18
src/server/config/normalizer.ts
Normal file
18
src/server/config/normalizer.ts
Normal 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 };
|
||||
}
|
||||
120
src/server/config/schema/builder.ts
Normal file
120
src/server/config/schema/builder.ts
Normal 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;
|
||||
}
|
||||
5
src/server/config/schema/export.ts
Normal file
5
src/server/config/schema/export.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createExternalConfigSchema } from "./builder";
|
||||
|
||||
export function createConfigJsonSchema(): Record<string, unknown> {
|
||||
return createExternalConfigSchema();
|
||||
}
|
||||
3
src/server/config/schema/fragments.ts
Normal file
3
src/server/config/schema/fragments.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);
|
||||
110
src/server/config/schema/validate.ts
Normal file
110
src/server/config/schema/validate.ts
Normal 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
124
src/server/config/types.ts
Normal 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;
|
||||
}
|
||||
188
src/server/config/variables.ts
Normal file
188
src/server/config/variables.ts
Normal 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
13
src/server/dev.ts
Normal 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
48
src/server/helpers.ts
Normal 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
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];
|
||||
}
|
||||
13
src/server/main.ts
Normal file
13
src/server/main.ts
Normal 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
63
src/server/middleware.ts
Normal 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() };
|
||||
}
|
||||
7
src/server/routes/meta.ts
Normal file
7
src/server/routes/meta.ts
Normal 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
49
src/server/server.ts
Normal 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
60
src/server/static.ts
Normal 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
17
src/server/version.ts
Normal 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;
|
||||
}
|
||||
18
src/shared/api.ts
Normal file
18
src/shared/api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface MetaResponse {
|
||||
ok: true;
|
||||
service: string;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export type RuntimeMode = "development" | "production" | "test";
|
||||
|
||||
// ==========================================
|
||||
// 在此定义你的业务类型
|
||||
// 前后端共享的类型都放在这个文件中
|
||||
// ==========================================
|
||||
6
src/shared/app.ts
Normal file
6
src/shared/app.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const APP = {
|
||||
description: "基于 Bun + React + TDesign 的全栈开发框架",
|
||||
name: "my-app",
|
||||
subtitle: "Bun 全栈应用",
|
||||
title: "My App",
|
||||
} as const;
|
||||
86
src/web/app.tsx
Normal file
86
src/web/app.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { Layout, RadioGroup } from "tdesign-react";
|
||||
|
||||
import type { MetaResponse } from "../shared/api";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
||||
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
|
||||
import { MENU_ITEMS } from "./menu";
|
||||
import { AppRoutes } from "./routes";
|
||||
|
||||
const { Aside, Content, Header } = Layout;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ label: "系统", value: "system" },
|
||||
{ label: "明亮", value: "light" },
|
||||
{ label: "黑暗", value: "dark" },
|
||||
] as const;
|
||||
|
||||
export function App() {
|
||||
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
||||
const { collapsed, toggleCollapsed } = useSidebarCollapsed();
|
||||
const location = useLocation();
|
||||
const { data: meta } = useQuery({
|
||||
queryFn: fetchMeta,
|
||||
queryKey: ["meta"],
|
||||
refetchInterval: 30000,
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.title = APP.title;
|
||||
document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description);
|
||||
}, []);
|
||||
|
||||
const handleThemeChange = (value: ThemePreference) => {
|
||||
setThemePreference(value);
|
||||
};
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
||||
const pageTitle = currentItem?.label ?? APP.title;
|
||||
const versionDisplay = meta?.version ? `v${meta.version}` : null;
|
||||
|
||||
return (
|
||||
<Layout className="app-layout">
|
||||
<Header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<span className="app-brand-group">
|
||||
<span className="app-brand">{APP.title}</span>
|
||||
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
|
||||
</span>
|
||||
<span className="app-page-title">{pageTitle}</span>
|
||||
</div>
|
||||
<div className="app-header-right">
|
||||
<RadioGroup
|
||||
onChange={handleThemeChange}
|
||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||
theme="button"
|
||||
value={themePreference}
|
||||
variant="default-filled"
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<Layout>
|
||||
<Aside className="app-sidebar" width={collapsed ? "64px" : "232px"}>
|
||||
<Sidebar collapsed={collapsed} onToggleCollapsed={toggleCollapsed} />
|
||||
</Aside>
|
||||
<Layout>
|
||||
<Content className="app-content">
|
||||
<AppRoutes />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchMeta(): Promise<MetaResponse> {
|
||||
const response = await fetch("/api/meta");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<MetaResponse>;
|
||||
}
|
||||
38
src/web/components/ErrorBoundary.tsx
Normal file
38
src/web/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
|
||||
import { Component } from "react";
|
||||
import { Alert, Button, Space } from "tdesign-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
override state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("渲染错误:", error, info.componentStack);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Space align="center" className="error-boundary-fallback" direction="vertical" size="large">
|
||||
<Alert message="页面渲染出现异常,请刷新重试" theme="error" title="页面出错" />
|
||||
<Button onClick={() => window.location.reload()} theme="primary">
|
||||
刷新页面
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
53
src/web/components/Sidebar/index.tsx
Normal file
53
src/web/components/Sidebar/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "tdesign-icons-react";
|
||||
import { Button, Menu } from "tdesign-react";
|
||||
|
||||
import { MENU_ITEMS } from "../../menu";
|
||||
|
||||
const { MenuItem } = Menu;
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed, onToggleCollapsed }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
||||
const activeValue = currentItem?.value ?? "";
|
||||
|
||||
const handleMenuChange = (value: number | string) => {
|
||||
const item = MENU_ITEMS.find((item) => item.value === value);
|
||||
if (item) {
|
||||
void navigate(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className="app-sidebar-menu"
|
||||
collapsed={collapsed}
|
||||
onChange={handleMenuChange}
|
||||
operations={
|
||||
<Button
|
||||
className="app-sidebar-collapse-btn"
|
||||
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
onClick={onToggleCollapsed}
|
||||
shape="square"
|
||||
variant="text"
|
||||
/>
|
||||
}
|
||||
value={activeValue}
|
||||
width={collapsed ? "64px" : "232px"}
|
||||
>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<MenuItem icon={item.icon} key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
1
src/web/css.d.ts
vendored
Normal file
1
src/web/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
51
src/web/hooks/use-sidebar-collapsed.ts
Normal file
51
src/web/hooks/use-sidebar-collapsed.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebar.collapsed";
|
||||
|
||||
export function applyInitialSidebarCollapsed() {
|
||||
const collapsed = readSidebarCollapsed();
|
||||
applySidebarCollapsed(collapsed);
|
||||
}
|
||||
|
||||
export function applySidebarCollapsed(collapsed: boolean, root: HTMLElement = document.documentElement) {
|
||||
root.setAttribute("data-sidebar-collapsed", String(collapsed));
|
||||
}
|
||||
|
||||
export function parseSidebarCollapsed(value: unknown): boolean {
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
export function readSidebarCollapsed(storage: Storage = window.localStorage): boolean {
|
||||
try {
|
||||
return parseSidebarCollapsed(storage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSidebarCollapsed() {
|
||||
const [collapsed, setCollapsedState] = useState<boolean>(() => readSidebarCollapsed());
|
||||
|
||||
useEffect(() => {
|
||||
applySidebarCollapsed(collapsed);
|
||||
}, [collapsed]);
|
||||
|
||||
const setCollapsed = (nextCollapsed: boolean) => {
|
||||
setCollapsedState(nextCollapsed);
|
||||
writeSidebarCollapsed(nextCollapsed);
|
||||
};
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed(!collapsed);
|
||||
};
|
||||
|
||||
return { collapsed, setCollapsed, toggleCollapsed };
|
||||
}
|
||||
|
||||
export function writeSidebarCollapsed(collapsed: boolean, storage: Storage = window.localStorage) {
|
||||
try {
|
||||
storage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed));
|
||||
} catch {
|
||||
// 存储不可用时仅使用当前内存状态
|
||||
}
|
||||
}
|
||||
73
src/web/hooks/use-theme-preference.ts
Normal file
73
src/web/hooks/use-theme-preference.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type EffectiveTheme = "dark" | "light";
|
||||
export type ThemePreference = "dark" | "light" | "system";
|
||||
|
||||
export const THEME_PREFERENCE_STORAGE_KEY = "theme.preference";
|
||||
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
export function applyInitialThemePreference() {
|
||||
applyThemeMode(resolveEffectiveTheme(readThemePreference(), getSystemPrefersDark()));
|
||||
}
|
||||
|
||||
export function applyThemeMode(theme: EffectiveTheme, root: HTMLElement = document.documentElement) {
|
||||
root.setAttribute("theme-mode", theme);
|
||||
}
|
||||
|
||||
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
|
||||
try {
|
||||
return matchMedia(THEME_MEDIA_QUERY).matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseThemePreference(value: unknown): ThemePreference {
|
||||
return value === "dark" || value === "light" || value === "system" ? value : "system";
|
||||
}
|
||||
|
||||
export function readThemePreference(storage: Storage = window.localStorage): ThemePreference {
|
||||
try {
|
||||
return parseThemePreference(storage.getItem(THEME_PREFERENCE_STORAGE_KEY));
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme {
|
||||
if (preference === "dark" || preference === "light") return preference;
|
||||
return systemPrefersDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function useThemePreference() {
|
||||
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
|
||||
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeMode(effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY);
|
||||
|
||||
const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches);
|
||||
mediaQueryList.addEventListener("change", handleChange);
|
||||
return () => mediaQueryList.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
const setPreference = (nextPreference: ThemePreference) => {
|
||||
setPreferenceState(nextPreference);
|
||||
writeThemePreference(nextPreference);
|
||||
};
|
||||
|
||||
return { effectiveTheme, preference, setPreference };
|
||||
}
|
||||
|
||||
export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) {
|
||||
try {
|
||||
storage.setItem(THEME_PREFERENCE_STORAGE_KEY, preference);
|
||||
} catch {
|
||||
// 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。
|
||||
}
|
||||
}
|
||||
13
src/web/index.html
Normal file
13
src/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="" />
|
||||
<title>App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
46
src/web/main.tsx
Normal file
46
src/web/main.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
|
||||
import { App } from "./app";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { applyInitialSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
||||
import { applyInitialThemePreference } from "./hooks/use-theme-preference";
|
||||
import "tdesign-react/dist/reset.css";
|
||||
import "tdesign-react/dist/tdesign.min.css";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
staleTime: 5000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("找不到前端挂载节点 #root");
|
||||
}
|
||||
|
||||
applyInitialThemePreference();
|
||||
applyInitialSidebarCollapsed();
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
18
src/web/menu.tsx
Normal file
18
src/web/menu.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { MenuValue } from "tdesign-react";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { DashboardIcon, SettingIcon, UserIcon } from "tdesign-icons-react";
|
||||
|
||||
export interface MenuItemConfig {
|
||||
icon: ReactElement;
|
||||
label: string;
|
||||
path: string;
|
||||
value: MenuValue;
|
||||
}
|
||||
|
||||
export const MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(DashboardIcon), label: "仪表盘", path: "/", value: "dashboard" },
|
||||
{ icon: createElement(UserIcon), label: "用户管理", path: "/users", value: "users" },
|
||||
{ icon: createElement(SettingIcon), label: "系统设置", path: "/settings", value: "settings" },
|
||||
] as const;
|
||||
22
src/web/pages/404/index.tsx
Normal file
22
src/web/pages/404/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useNavigate } from "react-router";
|
||||
import { ErrorCircleIcon } from "tdesign-icons-react";
|
||||
import { Button, Space } from "tdesign-react";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoHome = () => {
|
||||
void navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<Space align="center" className="not-found-page" direction="vertical" size="large">
|
||||
<ErrorCircleIcon className="not-found-icon" size="64px" />
|
||||
<h1>404</h1>
|
||||
<p>您访问的页面不存在</p>
|
||||
<Button onClick={handleGoHome} theme="primary">
|
||||
返回首页
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
29
src/web/pages/dashboard/index.tsx
Normal file
29
src/web/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Space } from "tdesign-react";
|
||||
|
||||
import type { MetaResponse } from "../../../shared/api";
|
||||
|
||||
import { APP } from "../../../shared/app";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { data: meta } = useQuery({
|
||||
queryFn: fetchMeta,
|
||||
queryKey: ["meta"],
|
||||
refetchInterval: 30000,
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<h2>欢迎使用 {APP.title}</h2>
|
||||
<p>在此构建你的应用。以下是 /api/meta 的返回数据(前后端联调示例):</p>
|
||||
{meta && <pre className="meta-response">{JSON.stringify(meta, null, 2)}</pre>}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchMeta(): Promise<MetaResponse> {
|
||||
const response = await fetch("/api/meta");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<MetaResponse>;
|
||||
}
|
||||
12
src/web/pages/settings/index.tsx
Normal file
12
src/web/pages/settings/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Card, Space } from "tdesign-react";
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<h2>系统设置</h2>
|
||||
<Card>
|
||||
<p>页面建设中...</p>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
12
src/web/pages/users/index.tsx
Normal file
12
src/web/pages/users/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Card, Space } from "tdesign-react";
|
||||
|
||||
export function UsersPage() {
|
||||
return (
|
||||
<Space className="full-width-space" direction="vertical" size="large">
|
||||
<h2>用户管理</h2>
|
||||
<Card>
|
||||
<p>页面建设中...</p>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
23
src/web/routes.tsx
Normal file
23
src/web/routes.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Route, Routes } from "react-router";
|
||||
|
||||
import { NotFoundPage } from "./pages/404";
|
||||
import { DashboardPage } from "./pages/dashboard";
|
||||
import { SettingsPage } from "./pages/settings";
|
||||
import { UsersPage } from "./pages/users";
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<DashboardPage />} path="/" />
|
||||
<Route element={<UsersPage />} path="/users" />
|
||||
<Route element={<SettingsPage />} path="/settings" />
|
||||
<Route element={<NotFoundPage />} path="*" />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
115
src/web/styles.css
Normal file
115
src/web/styles.css
Normal file
@@ -0,0 +1,115 @@
|
||||
:root {
|
||||
--td-brand-color: var(--td-brand-color-7);
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--td-bg-color-page);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--td-comp-paddingLR-l);
|
||||
background: var(--td-bg-color-container);
|
||||
border-bottom: 1px solid var(--td-component-border);
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.app-header-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-l);
|
||||
}
|
||||
|
||||
.app-header-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-s);
|
||||
}
|
||||
|
||||
.app-brand-group {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--td-comp-margin-s);
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: calc(var(--td-font-size-title-large) + 6px);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: var(--td-font-size-body-small);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-sidebar-collapse-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.app-page-title {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: var(--td-font-size-title-medium);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
background: var(--td-bg-color-container);
|
||||
border-right: 1px solid var(--td-component-border);
|
||||
height: calc(100vh - 64px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-sidebar-menu {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
box-sizing: border-box;
|
||||
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.meta-response {
|
||||
background: var(--td-bg-color-component);
|
||||
border-radius: var(--td-radius-default);
|
||||
padding: var(--td-comp-paddingTB-l) var(--td-comp-paddingLR-l);
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
color: var(--td-text-color-primary);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.error-boundary-fallback {
|
||||
padding-top: 20vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-disabled {
|
||||
color: var(--td-text-color-disabled);
|
||||
}
|
||||
|
||||
.full-width-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.not-found-icon {
|
||||
color: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
46
src/web/utils/time.ts
Normal file
46
src/web/utils/time.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export function formatCountdown(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
return `${Math.floor(seconds / 60)}分${seconds % 60}秒`;
|
||||
}
|
||||
|
||||
export function formatDurationUnit(ms: null | number): { suffix: string; value: number } {
|
||||
if (ms === null) return { suffix: "", value: 0 };
|
||||
if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) };
|
||||
if (ms < 3600000) return { suffix: "分钟", value: roundToOne(ms / 60000) };
|
||||
return { suffix: "小时", value: roundToOne(ms / 3600000) };
|
||||
}
|
||||
|
||||
export function formatRelativeTime(timestamp: null | string, now = new Date()): string {
|
||||
if (!timestamp) return "尚无检查数据";
|
||||
|
||||
const time = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(time)) return "尚无检查数据";
|
||||
|
||||
const diffSeconds = Math.max(0, Math.floor((now.getTime() - time) / 1000));
|
||||
if (diffSeconds < 60) return `${diffSeconds}秒前`;
|
||||
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}小时前`;
|
||||
|
||||
return `${Math.floor(diffHours / 24)}天前`;
|
||||
}
|
||||
|
||||
export function isOlderThan(timestamp: null | string, ageMs: number, now = new Date()): boolean {
|
||||
if (!timestamp) return false;
|
||||
const time = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(time)) return false;
|
||||
return now.getTime() - time > ageMs;
|
||||
}
|
||||
|
||||
export function subtractHours(date: Date, hours: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
|
||||
return result;
|
||||
}
|
||||
|
||||
function roundToOne(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
Reference in New Issue
Block a user