oak-cli/src/server/watch.ts

738 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import chokidar from "chokidar";
import ts, { CompilerOptions, ProjectReference } from "typescript";
import pathLib from "path";
import dayjs from "dayjs";
import fs from "fs";
import { cloneDeep } from "lodash";
declare const require: NodeRequire;
declare const process: NodeJS.Process;
export type LogFormatterProp = {
level: "info" | "warn" | "error";
caller: NodeJS.CallSite | null;
args: any[];
};
export type LogFormatter = (props: LogFormatterProp) => any[];
export type WatchConfig = {
/**
* 是否启用polyfill
*/
polyfill?: {
/**
* 是否启用console polyfill
*/
console?: {
/**
* 是否启用
*/
enable?: boolean;
/**
* 是否打印调用堆栈信息
*/
trace?: boolean;
/**
* 格式化函数
*/
formatter: LogFormatter;
};
};
/**
* 生命周期钩子
*/
lifecycle?: {
/**
* 初始化时调用
* @returns void
*/
onInit?: () => void;
/**
* 服务启动时调用
* @returns void
*/
onServerStart?: () => void;
/**
* 编译前调用
* @returns void
*/
onBeforeCompile?: () => void;
/**
* 编译后调用
* @returns void
*/
onAfterCompile?: () => void;
/**
* 服务关闭时调用
* @returns void
*/
onServerShutdown?: () => void;
/**
* 销毁监视器时调用
* @returns void
*/
onDispose?: () => void;
};
};
// 真实config不会出现,所以这里新增一个type表示真实的config
type DeepRequiredIfPresent<T> = {
[P in keyof T]-?: NonNullable<T[P]> extends (...args: any) => any // Check for function type
? T[P] // Preserve the original function type
: NonNullable<T[P]> extends (infer U)[]
? DeepRequiredIfPresent<U>[]
: NonNullable<T[P]> extends object
? DeepRequiredIfPresent<NonNullable<T[P]>>
: T[P] extends undefined | null
? T[P]
: NonNullable<T[P]>;
};
export type RealWatchConfig = DeepRequiredIfPresent<WatchConfig>;
const defaultConfig: RealWatchConfig = {
polyfill: {
console: {
enable: true,
trace: true,
formatter: ({ level, caller, args }) => {
const getFileInfo = () => {
if (!caller) {
return "";
}
const fileInfo = caller
? `${caller.getFileName()}:${caller.getLineNumber()}`
: "";
return fileInfo.trim();
};
const getTime = () => dayjs().format("YYYY-MM-DD HH:mm:ss.SSS");
const infoStart = "\x1B[36m[ Info";
const warnStart = "\x1B[33m[ Warn";
const errorStart = "\x1B[31m[ Error";
const clearColor = caller ? "]\x1B[0m\n" : "]\x1B[0m";
const levelStart =
level === "info"
? infoStart
: level === "warn"
? warnStart
: errorStart;
return [
levelStart,
getTime(),
getFileInfo(),
clearColor,
...args,
];
},
},
},
lifecycle: {
onInit: () => {
console.log("----> Watcher initialized.");
},
onServerStart: () => {
console.log("----> Server started.");
},
onBeforeCompile: () => {
console.log("----> Compiling......");
},
onAfterCompile: () => {
console.log("----> Compile completed.");
},
onServerShutdown: () => {
console.log("----> Server shutdown.");
},
onDispose: () => {
console.log("----> Watcher disposed.");
},
},
};
const getOverrideConfig = (config?: WatchConfig): RealWatchConfig => {
// 遍历default里面的每一个key如果config里面有就覆盖没有就用default的
const overrideInner = (obj: any, dobj: any): any => {
const result: any = {};
Object.keys(dobj).forEach((key) => {
const value = dobj[key];
if (typeof value === "object") {
const v = obj[key];
if (v === undefined) {
result[key] = value;
} else {
result[key] = overrideInner(v, value);
}
} else {
if (obj && obj[key] !== undefined) {
result[key] = obj[key];
} else {
result[key] = value;
}
}
});
return result;
};
return config
? (overrideInner(config, defaultConfig) as RealWatchConfig)
: defaultConfig;
};
type AliasConfig = Record<string, string | string[]>;
type ModuleType = import('module')
/**
* 根据 alias 配置表将路径中的别名替换为真实路径
* @param path - 输入的路径字符串,例如 "@project/file" 或 "@oak-app-domain/some-module"
* @param aliasConfig - alias 配置表key 为别名value 为对应的真实路径或路径数组
* @returns 替换后的路径,如果没有匹配到 alias则返回原始路径
*/
function replaceAliasWithPath(path: string, aliasConfig: Record<string, string | string[]>): string {
for (const [alias, targets] of Object.entries(aliasConfig)) {
// If alias ends with "*", handle it as a dynamic alias.
if (alias.endsWith('*')) {
// Create a regex pattern that matches paths starting with the alias, followed by any characters
const aliasPattern = new RegExp(`^${alias.replace(/\*$/, "")}(.*)`); // e.g., '@project/*' becomes '@project/(.*)'
const match = path.match(aliasPattern);
if (match) {
// Replace the alias with the target path, appending the matched part from the original path
const target = Array.isArray(targets) ? targets[0] : targets; // Take the first target (if it's an array)
// Ensure that the target path ends with a slash if it's not already
const replacedPath = target.replace(/\/\*$/, "/") + match[1];
return replacedPath;
}
} else {
// Handle static alias without "*" by directly matching the path
if (path.startsWith(alias)) {
const target = Array.isArray(targets) ? targets[0] : targets; // Take the first target (if it's an array)
// Replace the alias part with the target path
return path.replace(alias, target);
}
}
}
// If no alias matches, return the original path
return path;
}
export const watch = (
projectPath: string,
config?: WatchConfig
): Promise<() => Promise<void>> => {
const realConfig = getOverrideConfig(config);
const enableTrace = !!process.env.ENABLE_TRACE;
// 查找配置文件
const configFileName = ts.findConfigFile(
projectPath,
ts.sys.fileExists,
"tsconfig.build.json"
);
if (!configFileName) {
throw new Error("Could not find a valid 'tsconfig.build.json'.");
}
// 读取配置文件
const configFile = ts.readConfigFile(configFileName, ts.sys.readFile);
// 解析配置文件内容
const { options, projectReferences } = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
pathLib.dirname(configFileName)
);
const aliasConfig: AliasConfig = cloneDeep(options.paths) || {};
// 输出原始配置
// console.log("[DEBUG] Original alias config:", aliasConfig);
Object.keys(aliasConfig).forEach((key) => {
const value = aliasConfig[key];
// 替换src
aliasConfig[key] = typeof value === "string" ? value.replace("src", "lib") : value.map((v) => v.replace("src", "lib"));
});
// 输出真实的alias配置
console.debug("[DEBUG] Running Alias config:", aliasConfig);
const createProgramAndSourceFile = (path: string, options: CompilerOptions, projectReferences: readonly ProjectReference[] | undefined) => {
const program = ts.createProgram({
rootNames: [path],
options,
projectReferences,
});
const sourceFile = program.getSourceFile(path);
// 是否有语法错误
const diagnostics = ts.getPreEmitDiagnostics(program, sourceFile);
if (diagnostics.length) {
const syntaxErrors = diagnostics.filter(
(diagnostic) =>
diagnostic.category === ts.DiagnosticCategory.Error
);
if (syntaxErrors.length) {
console.error(`Error in ${path}`);
syntaxErrors.forEach((diagnostic) => {
console.error(
`${ts.flattenDiagnosticMessageText(
diagnostic.messageText,
"\n"
)}`
);
});
console.error(`文件存在语法错误,请检查修复后重试!`);
return { program, sourceFile, diagnostics };
}
}
return { program, sourceFile, diagnostics };
}
// 这个函数用于解决require的时候如果文件不存在会尝试编译ts文件
// 测试在src下新建一个ts文件然后删除lib下的js文件然后require这个文件会发现会自动编译ts文件
const polyfillLoader = () => {
const BuiltinModule = require("module");
// 模拟环境下的 module 构造函数
const Module = module.constructor.length > 1 ? module.constructor : BuiltinModule;
// 保存原始 _resolveFilename 方法
const oldResolveFilename = Module._resolveFilename;
// 通用的路径检查与编译函数
function resolveAndCompile(requestPath: string, parent: ModuleType, options: CompilerOptions, projectReferences: readonly ts.ProjectReference[] | undefined) {
const jsPath = pathLib.resolve(requestPath + ".js");
const tsPath = jsPath.replace(/\.js$/, ".ts").replace(
pathLib.join(projectPath, "lib"),
pathLib.join(projectPath, "src")
);
// 检查并编译 .ts 文件
if (fs.existsSync(tsPath)) {
console.log(`[resolve] Found TypeScript source file: ${tsPath}, attempting to compile...`);
const { program, sourceFile, diagnostics } = createProgramAndSourceFile(
tsPath,
options,
projectReferences
);
if (diagnostics.length) {
console.error(`[resolve] Compilation failed for: ${tsPath}`);
throw new Error("TypeScript compilation error");
}
const emitResult = program.emit(sourceFile);
if (emitResult.emitSkipped) {
console.error(`[resolve] Emit skipped for: ${tsPath}`);
throw new Error("TypeScript emit skipped");
}
console.log(`[resolve] Successfully compiled: ${tsPath}`);
return jsPath;
}
// 如果没有找到对应的 .ts 文件
if (fs.existsSync(jsPath)) {
return jsPath;
}
throw new Error(`[resolve] Unable to find module: ${requestPath}`);
}
// 处理文件夹导入的情况
function resolveDirectory(requestPath: string, parent: ModuleType, options: CompilerOptions, projectReferences: readonly ts.ProjectReference[] | undefined) {
const indexJs = pathLib.join(requestPath, "index.js");
const indexTs = pathLib.join(requestPath, "index.ts").replace(
pathLib.join(projectPath, "lib"),
pathLib.join(projectPath, "src")
);
if (fs.existsSync(indexTs)) {
console.log(`[resolve] Found TypeScript index file: ${indexTs}, attempting to compile...`);
return resolveAndCompile(indexTs, parent, options, projectReferences);
}
if (fs.existsSync(indexJs)) {
return indexJs;
}
throw new Error(`[resolve] No index file found in directory: ${requestPath}`);
}
// 重写 _resolveFilename 方法
Module._resolveFilename = function (
request: string, // 模块请求路径
parent: ModuleType, // 调用方模块
isMain: boolean, // 是否是主模块
rFoptions: object | undefined // 解析选项
) {
let resolvedRequest = request;
const replacedPath = replaceAliasWithPath(request, aliasConfig);
if (replacedPath !== request) {
console.log(`[resolve] Alias resolved: ${request} -> ${replacedPath}`);
resolvedRequest = pathLib.join(projectPath, replacedPath);
}
try {
return oldResolveFilename.call(this, resolvedRequest, parent, isMain, rFoptions);
} catch (error: any) {
if (error.code === "MODULE_NOT_FOUND") {
const requestPath = pathLib.resolve(pathLib.dirname(parent.filename), resolvedRequest);
// 处理文件夹导入
if (fs.existsSync(requestPath) && fs.statSync(requestPath).isDirectory()) {
return resolveDirectory(requestPath, parent, options, projectReferences);
}
// 处理单文件导入
return resolveAndCompile(requestPath, parent, options, projectReferences);
}
throw error;
}
};
};
polyfillLoader();
//polyfill console.log 添加时间
const polyfillConsole = (trace: boolean) => {
// 获取调用堆栈信息
const getCallerInfo = (): NodeJS.CallSite | null => {
if (!trace) {
return null;
}
const originalFunc = Error.prepareStackTrace;
let callerInfo: NodeJS.CallSite | null = null;
try {
const err = new Error();
Error.prepareStackTrace = (err, stack) => stack;
const stack = err.stack as unknown as NodeJS.CallSite[]; // Type assertion here
const currentFile = stack[0].getFileName();
for (let i = 1; i < stack.length; i++) {
// Start from index 1
const callSite = stack[i];
if (currentFile !== callSite.getFileName()) {
callerInfo = callSite;
break;
}
}
} catch (e) {
console.error(e);
}
Error.prepareStackTrace = originalFunc;
return callerInfo;
};
// polyfill console.log 添加时间和文件位置
["info", "warn", "error"].forEach((level: string) => {
const levelStr = level as "info" | "warn" | "error";
const oldFunc = console[levelStr];
console[levelStr] = function (...args) {
oldFunc(
...(defaultConfig.polyfill?.console?.formatter({
level: levelStr,
caller: getCallerInfo(),
args,
}) || [])
);
};
});
};
realConfig.polyfill.console.enable && polyfillConsole(enableTrace);
// 这里注意要在require之前因为require会触发编译
const { startup } = require('./start') as {
startup: (pwd: string, connector: any) => Promise<() => Promise<void>>;
}
// 如果lib目录是空的则直接编译所有的ts文件
const serverConfigFile = pathLib.join(projectPath, "lib/configuration/server.js");
if (!fs.existsSync(serverConfigFile)) {
// 尝试编译src/configuration/server.ts
console.log(`[watch] Server configuration file not found, attempting to compile the project......`);
const tryCompile = (tsFile: string) => {
console.log(`[watch] Compiling: ${tsFile}`);
if (fs.existsSync(tsFile)) {
const { program, diagnostics } = createProgramAndSourceFile(tsFile, options, projectReferences);
if (diagnostics.length) {
console.error(`Error in ${tsFile}`);
diagnostics.forEach((diagnostic) => {
console.error(
`${ts.flattenDiagnosticMessageText(
diagnostic.messageText,
"\n"
)}`
);
});
console.error(`文件存在语法错误,请检查修复后重试!`);
process.exit(1);
}
// const emitResult = program.emit(sourceFile);
// 编译所有的文件
const emitResult = program.emit();
if (emitResult.emitSkipped) {
console.error(`Emit failed for ${tsFile}!`);
process.exit(1);
}
console.log(`Emit succeeded. ${tsFile} has been compiled.`);
}
}
// 所有要编译的目录
// 其他涉及到的目录会在运行的时候自动编译这里主要处理的是动态require的文件
const compileFiles = [
"src/configuration/index.ts",
"src/aspects/index.ts",
"src/checkers/index.ts",
"src/triggers/index.ts",
"src/timers/index.ts",
"src/routines/start.ts",
"src/watchers/index.ts",
"src/endpoints/index.ts",
"src/data/index.ts",
"src/ports/index.ts",
];
compileFiles.forEach(tryCompile);
}
return new Promise((resolve, reject) => {
realConfig.lifecycle.onInit();
let shutdown: (() => Promise<void>) | undefined;
const restart = async () => {
if (shutdown) {
console.log("----> Shutting down service......");
await shutdown().then(realConfig.lifecycle.onServerShutdown);
// reset shutdown
shutdown = undefined;
}
console.warn("----> Clearing require cache of project......");
let deleteCount = 0;
// 清空lib以下目录的缓存
Object.keys(require.cache).forEach((key) => {
// 如果不是项目目录下的文件,不删除
if (!key.startsWith(projectPath)) {
return;
} else if (
key.includes("lib") &&
!key.includes("node_modules")
) {
delete require.cache[key];
deleteCount++;
}
});
console.warn(
`----> ${deleteCount} modules has been removed from require.cache.`
);
const pwd = process.cwd();
// eslint-disable-next-line @typescript-eslint/no-var-requires
const simpleConnector = require(pathLib.join(
projectPath,
"lib/config/connector"
)).default;
console.warn("----> Starting service......");
shutdown = await startup(pwd, simpleConnector).then((shutdown) => {
realConfig.lifecycle.onServerStart();
return shutdown;
});
};
const watchSourcePath = pathLib.join(projectPath, "src");
console.log("Watching for changes in", watchSourcePath);
const watcher = chokidar.watch(watchSourcePath, {
persistent: true,
ignored: (file: string) =>
file.endsWith(".tsx") ||
file.endsWith(".xml") ||
file.includes("components") ||
file.includes("pages") ||
file.includes("hooks"),
interval: 100,
binaryInterval: 100,
cwd: projectPath,
depth: 99,
followSymlinks: true,
ignoreInitial: false,
ignorePermissionErrors: false,
usePolling: false,
alwaysStat: false,
});
let startWatching = false;
watcher.on("ready", () => {
console.warn("Initial scan complete. Ready for changes");
startWatching = true;
});
watcher.on("error", (error) => console.log(`Watcher error: ${error}`));
let processingQueue: string[] = [];
const fileChangeHandler = async (
path: string,
type: "add" | "remove" | "change"
) => {
// 判断一下是不是以下扩展名ts
if (!path.endsWith(".ts")) {
// 如果是json文件复制或者删除
if (path.endsWith(".json")) {
// const targetPath = path.replace("src", "lib"); // 这里直接替换不对,应该是拿到项目目录,把项目目录/src 换成项目目录/lib
const targetPath = path.replace(pathLib.join(projectPath, "src"), pathLib.join(projectPath, "lib"));
if (type === "remove") {
fs.unlinkSync(targetPath);
console.warn(`File ${targetPath} has been removed.`);
} else if (type === "add") {
fs.copyFileSync(
path,
targetPath,
fs.constants.COPYFILE_FICLONE
);
console.warn(
`File ${path} has been created at ${targetPath}.`
);
} else if (type === "change") {
// 强制覆盖文件
if (fs.existsSync(targetPath)) {
fs.unlinkSync(targetPath);
}
fs.copyFileSync(
path,
targetPath,
fs.constants.COPYFILE_FICLONE
);
console.warn(
`File ${path} has been copied to ${targetPath}.`
);
}
} else {
console.warn(`File ${path} is not [ts,json] file, skiped.`);
}
return;
}
// 控制台清空
console.clear();
console.warn(`File ${path} has been ${type}d`);
// 先判断一下这个文件在不在require.cache里面
const modulePath = pathLib.resolve(path);
// 将src替换为lib
const libPath = modulePath
.replace(pathLib.join(projectPath, "src"), pathLib.join(projectPath, "lib"))
.replace(/\.ts$/, ".js");
let compileOnly = false;
if (!require.cache[libPath]) {
// 如果是删除的话直接尝试删除lib下的文件
if (type === "remove") {
try {
fs.unlinkSync(libPath);
} catch (e) {
console.error(`Error in delete ${libPath}`, e);
}
console.warn(`File ${libPath} has been removed.`);
return;
}
// console.warn(
// `File ${libPath} is not in module cache, will compile only.`
// );
// compileOnly = true;
// 这里现在不需要仅编译了因为在require的时候会自动编译ts文件
} else {
// 如果是删除,则需要发出警告,文件正在被进程使用
if (type === "remove") {
console.error(`File ${libPath} is being used, skiped.`);
return;
}
}
const { program, sourceFile, diagnostics } = createProgramAndSourceFile(path, options, projectReferences);
if (diagnostics.length) {
return;
}
// 只输出单个文件
realConfig.lifecycle.onBeforeCompile();
const emitResult = program.emit(sourceFile);
// 是否成功
const result = emitResult.emitSkipped;
if (result) {
console.error(`Emit failed for ${path}!`);
realConfig.lifecycle.onAfterCompile();
} else {
console.log(
`Emit succeeded. ${compileOnly ? "" : "reload service......"
}`
);
realConfig.lifecycle.onAfterCompile();
if (compileOnly) {
return;
}
// await restart(); // 只有在队列里面的最后一个文件编译完成后才会重启服务
if (processingQueue.length === 1) {
await restart();
} else {
console.log("Waiting for other operations to complete...");
}
}
};
const onChangeDebounced =
async (path: string, type: "add" | "remove" | "change") => {
if (processingQueue.includes(path)) {
console.log("Processing, please wait...");
return;
}
try {
processingQueue.push(path);
await fileChangeHandler(path, type);
} catch (e) {
console.clear();
console.error(e);
process.exit(1);
} finally {
processingQueue = processingQueue.filter((p) => p !== path);
}
}
watcher
.on("add", async (path) => {
if (startWatching) {
await onChangeDebounced(path, "add");
}
})
.on("change", async (path) => {
if (startWatching) {
await onChangeDebounced(path, "change");
}
})
.on("unlink", async (path) => {
if (startWatching) {
await onChangeDebounced(path, "remove");
}
});
const dispose = async () => {
if (shutdown) {
await shutdown();
}
await watcher.close();
realConfig.lifecycle.onDispose();
};
restart()
.then(() => {
resolve(dispose);
})
.catch(reject);
});
};