diff --git a/README.md b/README.md index 600735e..16c545f 100644 --- a/README.md +++ b/README.md @@ -1 +1,181 @@ -# rsbuild-plugin-workspace-dev \ No newline at end of file +# rsbuild-plugin-workspace-dev + +Start monorepo sub-projects in topological order. + +`rsbuild-plugin-workspace-dev` is designed for monorepo development. It computes the dependency graph starting from the current project and starts sub-projects in topological order. + +

+ + npm version + + license +

+ +English | [简体中文](./README.zh-CN.md) + +## Usage + +Install: + +```bash +pnpm add rsbuild-plugin-workspace-dev -D +``` + +Register the plugin in `rsbuild.config.ts`: + +```ts +// rsbuild.config.ts +import { pluginWorkspaceDev } from "rsbuild-plugin-workspace-dev"; + +export default { + plugins: [pluginWorkspaceDev()], +}; +``` + +## Use Cases + +In a monorepo, one project may depend on multiple sub-projects, and those sub-projects can also depend on each other. + +For example, the monorepo contains an app and several lib packages: + +```ts +monorepo +├── app +└── lib1 +└── lib2 +└── lib3 +``` + +Here, app is built with Rsbuild, and lib is built with Rslib. The app depends on lib1 and lib2: + +```json +{ + "name": "app", + "dependencies": { + "lib1": "workspace:*", + "lib2": "workspace:*" + } +} +``` + +`lib2` depends on `lib3`: + +```json +{ + "name": "lib2", + "dependencies": { + "lib3": "workspace:*" + } +} +``` + +When you run `pnpm dev` under app, sub-projects start in topological order: first lib1 and lib3, then lib2, and finally app. Starting a lib refers to running its dev command, for example: + +```json +{ + "scripts": { + "dev": "rslib build --watch" + } +} +``` + +Whether a sub-project has finished starting is determined by matching sub-project logs. By default, logs from Rslib and tsup sub-projects are recognized. You can also provide a custom match function to determine when a sub-project is ready. + +## Options + +### projectConfig +Configure how sub-projects are started and define custom log matching logic. + +- Type: +``` +interface ProjectConfig { + /** + * Custom sub-project start command. Default is `dev` (runs `npm run dev`). + */ + command?: string; + /** + * Custom logic to detect when a sub-project has started. + * By default, logs from `Rsbuild` and `tsup` are supported. + */ + match?: (stdout: string) => boolean; + /** + * Whether to skip starting the current sub-project. Default is `false`. + * Useful for sub-projects that do not need to be started. + */ + skip?: boolean; +} +``` + +### ignoreSelf + +- Type: `boolean` +- Default: `true` + +Whether to ignore starting the current project. The default is `true`. In most cases, you start the current project manually, so the plugin does not interfere. + +Consider a scenario where docs and lib are in the same project, and docs needs to debug the output of lib. In this case, you want to run `pnpm doc` for the docs, while lib should run `pnpm dev`. After configuring this option in your Rspress config, starting `pnpm doc` will automatically run `pnpm dev` to start the lib sub-project. + +``` +├── docs +│ └── index.mdx +├── package.json +├── src +│ └── Button.tsx +├── rslib.config.ts +├── rspress.config.ts +``` + +``` +"scripts": { + "dev": "rslib build --watch", + "doc": "rspress dev" +}, +``` + +### cwd + +- Type: `string` +- Default: `process.cwd()` + +Set the current working directory. The default is the current project directory; usually no configuration is needed. + +### workspaceFileDir + +- Type: `string` +- Default: `process.cwd()` + +Set the directory where the workspace file resides. The default is the current project directory; usually no configuration is needed. + +## Frequently Asked Questions + +### Project startup stuck +Stuck may be due to slow sub-project builds, etc. The lack of log output is because, by default, sub-project logs are output all at once after startup (to avoid interleaving sub-project logs). You can enable debug mode by adding an environment variable, which will allow sub-project logs to be output in real time. + +``` +DEBUG=rsbuild pnpm dev +``` + +### Some projects don't need to start + +If some sub-projects don't need to start, simply configure `skip: true` for the specified project in `rsbuild.config.ts`. + +```ts +// rsbuild.config.ts +import { pluginWorkspaceDev } from "rsbuild-plugin-workspace-dev"; + +export default { + plugins: [ + pluginWorkspaceDev({ + projectConfig: { + lib1: { + skip: true, + }, + }, + }), + ], +}; +``` + +## License + +[MIT](./LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index 93ed97d..add0889 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1 +1,172 @@ -# @rsbuild/watch-dev-plugin \ No newline at end of file +# rsbuild-plugin-workspace-dev + +提供按拓扑顺序启动 monorepo 子项目的能力。 + +`rsbuild-plugin-workspace-dev` 用于 monorepo 开发场景,它支持从当前项目开始计算依赖关系生成拓扑图,按拓扑顺序启动子项目。 + +

+ + npm version + + license +

+ +## 使用 + +安装: + +```bash +pnpm add rsbuild-plugin-workspace-dev -D +``` + +在 `rsbuild.config.ts` 里注册插件: + +```ts +// rsbuild.config.ts +import { pluginWorkspaceDev } from "rsbuild-plugin-workspace-dev"; + +export default { + plugins: [pluginWorkspaceDev()], +}; +``` + +## 使用场景 + +在 monorepo 中,一个项目可能依赖多个子项目,而子项目之间也可能存在依赖关系。 + +比如 monorepo 中包含了一个 app 应用和多个 lib 包: + +```ts +monorepo +├── app +└── lib1 +└── lib2 +└── lib3 +``` + +其中,app 是基于 Rsbuild 构建的, lib 是基于 Rslib 构建的。app 依赖了 lib1 和 lib2: + +```json +{ + "name": "app", + "dependencies": { + "lib1": "workspace:*", + "lib2": "workspace:*" + } +} +``` + +lib2 依赖了 lib3: + +```json +{ + "name": "lib2", + "dependencies": { + "lib3": "workspace:*" + } +} +``` +此时在 app 下执行 `pnpm dev` 后,会按照拓扑顺序先启动 lib1 和 lib3,再启动 lib2,最后启动 app。此处启动 lib 指的是执行 lib 的 dev 命令 +```json +{ + "scripts": { + "dev": "rslib build --watch" + } +} +``` +识别子项目是否启动完成是通过匹配子项目日志实现的,默认支持匹配 Rslib、tsup 子项目,同时支持手动配置 match 匹配日志。 + +## 选项 + +### projectConfig +用于子项目的启动项配置和自定义日志匹配逻辑。 + +- **类型:**: +``` +interface ProjectConfig { + /** + * 自定义子项目启动命令,默认值为 `dev`, 即执行 `npm run dev`。 + */ + command?: string; + /** + * 自定义子项目启动完成匹配逻辑,默认支持 `Rsbuild`、`tsup` 子项目的日志匹配逻辑。 + */ + match?: (stdout: string) => boolean; + /** + * 是否跳过当前子项目的启动,默认值为 `false`,通常用于跳过一些不需要启动的子项目。 + */ + skip?: boolean; +} +``` + +### ignoreSelf + +- **类型:** `boolean` +- **默认值:** `true` +- +是否忽略当前项目的启动,默认值为 `true`。一般无需手动配置,当前项目通常由用户手动执行 dev 启动,无需插件干预。 + +考虑如下场景,docs 和 lib 是在同一个项目中,而 docs 需要调试 lib 的产物,此时需要启动 `pnpm doc` 命令,而 lib 则需要启动 `pnpm dev` 命令,配置该选项到 rspress 配置中后,启动 `pnpm doc` 时会自动执行 `pnpm dev` 命令,用于启动 lib 子项目。 +``` +├── docs +│ └── index.mdx +├── package.json +├── src +│ └── Button.tsx +├── rslib.config.ts +├── rspress.config.ts +``` +``` +"scripts": { + "dev": "rslib build --watch", + "doc": "rspress dev" +}, +``` + +### cwd + +- **类型:** `string` +- **默认值:** `process.cwd()` + +用于配置当前工作目录,默认值为当前项目目录,一般无需配置。 + +### workspaceFileDir + +- **类型:** `string` +- **默认值:** `process.cwd()` + +用于配置 workspace 文件目录,默认值为当前项目目录,一般无需配置。 + + +## 常见问题 + +### 启动项目时卡住 +卡住可能是因为子项目构建过慢等原因,没有日志输出是因为默认情况下子项目日志是启动完成后一次性输出的(为了避免子项目日志混和在一起交错输出),可以通过添加环境变量来开启调试模式,这会让子项目的日志实时输出。 +``` +DEBUG=rsbuild pnpm dev +``` + +### 某些项目无需启动 +如果某些子项目不需要启动,只需要在 `rsbuild.config.ts` 中给指定项目配置 `skip: true` 即可。 + +```ts +// rsbuild.config.ts +import { pluginWorkspaceDev } from "rsbuild-plugin-workspace-dev"; + +export default { + plugins: [ + pluginWorkspaceDev({ + projectConfig: { + lib1: { + skip: true, + }, + }, + }), + ], +}; +``` + + +## License + +[MIT](./LICENSE). diff --git a/src/constant.ts b/src/constant.ts index 4d09efa..28eaa21 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -1,3 +1,6 @@ export const PACKAGE_JSON = 'package.json'; -export const RSLIB_READY_MESSAGE = 'build complete, watching for changes'; export const DEBUG_LOG_TITLE = '[Rsbuild Workspace Dev Plugin]: '; + +export const RSLIB_READY_MESSAGE = 'build complete, watching for changes'; +export const MODERN_MODULE_READY_MESSAGE = 'Watching for file changes'; +export const TSUP_READY_MESSAGE = 'Watching for changes in'; diff --git a/src/logger.ts b/src/logger.ts index 71dfb70..f0e88d3 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; - import { DEBUG_LOG_TITLE } from './constant.js'; +import { isDebug } from './utils.js'; enum LogType { Stdout = 'stdout', @@ -38,7 +38,8 @@ export class Logger { } emitLogOnce(type: 'stdout' | 'stderr', log: string) { - console[logMap[type]](log); + const logWithName = `${chalk.hex('#808080').bold(this.name)}: ${log}`; + console[logMap[type]](logWithName); } reset(type: 'stdout' | 'stderr') { @@ -54,6 +55,9 @@ export class Logger { } flushStdout() { + if (isDebug) { + return; + } this.setBanner(this.name); this.emitLog(LogType.Stdout); } @@ -67,9 +71,8 @@ export class Logger { } } -export const debugLog = (msg: string) => { - const isDebug = process.env.DEBUG === 'rsbuild' || process.env.DEBUG === '*'; +export const debugLog = (msg: string, prefix = DEBUG_LOG_TITLE) => { if (isDebug) { - console.log(DEBUG_LOG_TITLE + msg); + console.log(prefix + msg); } }; diff --git a/src/utils.ts b/src/utils.ts index 0614fe3..123613f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -24,3 +24,6 @@ export const readPackageJson = async ( ): Promise => { return readJson(pkgJsonFilePath); }; + +export const isDebug = + process.env.DEBUG === 'rsbuild' || process.env.DEBUG === '*'; diff --git a/src/workspace-dev.ts b/src/workspace-dev.ts index aac6c36..7da542f 100644 --- a/src/workspace-dev.ts +++ b/src/workspace-dev.ts @@ -5,8 +5,10 @@ import path from 'path'; import { DEBUG_LOG_TITLE, + MODERN_MODULE_READY_MESSAGE, PACKAGE_JSON, RSLIB_READY_MESSAGE, + TSUP_READY_MESSAGE, } from './constant.js'; import { debugLog, Logger } from './logger.js'; import { readPackageJson } from './utils.js'; @@ -19,20 +21,22 @@ interface GraphNode { export interface WorkspaceDevRunnerOptions { cwd?: string; - workspaceFilePath?: string; + workspaceFileDir?: string; projectConfig?: Record< string, { match?: (stdout: string) => boolean; command?: string; + skip?: boolean; } >; + ignoreSelf?: boolean; } export class WorkspaceDevRunner { private options: WorkspaceDevRunnerOptions; private cwd: string; - private workspaceFilePath: string; + private workspaceFileDir: string; private packages: Package[] = []; private graph: Graph; private visited: Record; @@ -41,9 +45,12 @@ export class WorkspaceDevRunner { private metaData!: Package['packageJson']; constructor(options: WorkspaceDevRunnerOptions) { - this.options = options; + this.options = { + ignoreSelf: true, + ...options, + }; this.cwd = options.cwd || process.cwd(); - this.workspaceFilePath = options.workspaceFilePath || this.cwd; + this.workspaceFileDir = options.workspaceFileDir || this.cwd; this.packages = []; this.visited = {}; this.visiting = {}; @@ -64,7 +71,7 @@ export class WorkspaceDevRunner { } buildDependencyGraph() { - const { packages } = getPackagesSync(this.workspaceFilePath); + const { packages } = getPackagesSync(this.workspaceFileDir); const currentPackage = packages.find( (pkg) => pkg.packageJson.name === this.metaData.name, )!; @@ -102,7 +109,9 @@ export class WorkspaceDevRunner { const depPackage = packages.find( (pkg) => pkg.packageJson.name === depName, )!; - initNode(depPackage); + if (!this.getNode(depName)) { + initNode(depPackage); + } } } }; @@ -111,47 +120,30 @@ export class WorkspaceDevRunner { } checkGraph() { - const isAcyclic = graphlib.alg.isAcyclic(this.graph); - if (!isAcyclic) { + const cycles = graphlib.alg.findCycles(this.graph); + const nonSelfCycles = cycles.filter((c) => c.length !== 1); + debugLog(`cycles check: ${cycles}`); + if (nonSelfCycles.length) { throw new Error( - DEBUG_LOG_TITLE + 'Dependency graph do not allow cycles.', + `${DEBUG_LOG_TITLE}Dependency graph do not allow cycles.`, ); } - return isAcyclic; - } - - getDependencyGraph() { - return this.graph; - } - - getNodes() { - return this.graph.nodes(); - } - - getEdges() { - return this.graph.edges(); - } - - getNode(name: string) { - return this.graph.node(name); - } - - getDependents(packageName: string) { - return this.graph.predecessors(packageName); - } - - getDependencies(packageName: string) { - return this.graph.successors(packageName); } async start() { const promises = []; - const nodes = this.getNodes().filter((node) => node !== this.metaData.name); + const allNodes = this.getNodes(); + const filterSelfNodes = allNodes.filter( + (node) => node !== this.metaData.name, + ); + const nodes = this.options.ignoreSelf ? filterSelfNodes : allNodes; + for (const node of nodes) { const dependencies = this.getDependencies(node) || []; const canStart = dependencies.every((dep) => { + const selfStart = node === dep; const isVisiting = this.visiting[dep]; - const isVisited = this.visited[dep]; + const isVisited = selfStart || this.visited[dep]; return isVisited && !isVisiting; }); @@ -166,9 +158,19 @@ export class WorkspaceDevRunner { visitNodes(node: string): Promise { return new Promise((resolve) => { - this.visiting[node] = true; const { name, path } = this.getNode(node); + const logger = new Logger({ + name, + }); const config = this.options?.projectConfig?.[name]; + if (config?.skip) { + this.visited[node] = true; + this.visiting[node] = false; + debugLog(`Skip visit node: ${node}`); + logger.emitLogOnce('stdout', `skip visit node: ${name}`); + return this.start().then(() => resolve()); + } + this.visiting[node] = true; const child = spawn( 'npm', @@ -183,20 +185,21 @@ export class WorkspaceDevRunner { }, ); - const logger = new Logger({ - name, - }); child.stdout.on('data', async (data) => { const stdout = data.toString(); + const content = data.toString().replace(/\n$/, ''); if (this.matched[node]) { - logger.emitLogOnce('stdout', stdout); + logger.emitLogOnce('stdout', content); return; } + debugLog(content, `${name}: `); logger.appendLog('stdout', stdout); const match = config?.match; const matchResult = match ? match(stdout) - : stdout.match(RSLIB_READY_MESSAGE); + : stdout.match(RSLIB_READY_MESSAGE) || + stdout.match(MODERN_MODULE_READY_MESSAGE) || + stdout.match(TSUP_READY_MESSAGE); if (matchResult) { logger.flushStdout(); @@ -216,4 +219,28 @@ export class WorkspaceDevRunner { child.on('close', () => {}); }); } + + getDependencyGraph() { + return this.graph; + } + + getNodes() { + return this.graph.nodes(); + } + + getEdges() { + return this.graph.edges(); + } + + getNode(name: string) { + return this.graph.node(name); + } + + getDependents(packageName: string) { + return this.graph.predecessors(packageName); + } + + getDependencies(packageName: string) { + return this.graph.successors(packageName); + } }