-
Notifications
You must be signed in to change notification settings - Fork 32
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: ♻️ added Workspace class for managing packages in monorepo
- Loading branch information
Showing
2 changed files
with
167 additions
and
171 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
import { parse } from "comment-json" | ||
import fs from "fs" | ||
import path from "path" | ||
import yaml from "yaml" | ||
import { findUp, getPackage } from "./package" | ||
|
||
export enum WorkspaceProviderType { | ||
single = "single", | ||
lerna = "lerna", | ||
yarn = "yarn", | ||
pnpm = "pnpm", | ||
rush = "rush", | ||
recursive = "recursive", | ||
} | ||
|
||
type WorkspaceProviderInfo = { root: string; patterns: string[] } | undefined | ||
|
||
type WorkspaceProvider = ( | ||
cwd: string | ||
) => WorkspaceProviderInfo | Promise<WorkspaceProviderInfo> | ||
|
||
export const providers: Record<WorkspaceProviderType, WorkspaceProvider> = { | ||
yarn: cwd => { | ||
let root = findUp("package.json", cwd) | ||
while (root) { | ||
const pkg = getPackage(root) | ||
if (pkg?.workspaces) { | ||
if (Array.isArray(pkg.workspaces)) | ||
return { root, patterns: pkg.workspaces } | ||
if (Array.isArray(pkg.workspaces.packages)) | ||
return { root, patterns: pkg.workspaces.packages } | ||
} | ||
root = findUp("package.json", path.resolve(path.dirname(root), "..")) | ||
} | ||
}, | ||
|
||
pnpm: cwd => { | ||
const root = findUp("pnpm-workspace.yaml", cwd) | ||
if (root) { | ||
const y = yaml.parse( | ||
fs.readFileSync(path.resolve(root, "pnpm-workspace.yaml"), "utf8") | ||
) | ||
if (y.packages) return { root, patterns: y.packages } | ||
} | ||
}, | ||
|
||
lerna: cwd => { | ||
const root = findUp("lerna.json", cwd) | ||
if (root) | ||
return { | ||
root, | ||
patterns: require(path.resolve(root, "lerna.json")) | ||
.packages as string[], | ||
} | ||
}, | ||
|
||
rush: cwd => { | ||
const root = findUp("rush.json", cwd) | ||
if (root) | ||
return { | ||
root, | ||
patterns: parse( | ||
fs.readFileSync(path.resolve(root, "rush.json")).toString() | ||
)?.projects.map((p: { projectFolder?: string }) => p.projectFolder), | ||
} | ||
}, | ||
|
||
recursive: cwd => { | ||
return { root: cwd, patterns: ["*/**"] } | ||
}, | ||
|
||
single: cwd => { | ||
const root = findUp("package.json", cwd) | ||
if (root) return { root, patterns: [root] } | ||
}, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,191 +1,111 @@ | ||
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore | ||
// @ts-ignore | ||
import { parse } from "comment-json" | ||
import fs from "fs" | ||
import globrex from "globrex" | ||
import path from "path" | ||
// eslint-disable-next-line import/default | ||
import tinyGlob from "tiny-glob" | ||
import yaml from "yaml" | ||
|
||
export type PackageJson = { | ||
name?: string | ||
scripts?: { [key: string]: string } | ||
dependencies?: { [key: string]: string } | ||
ultra?: { | ||
concurrent?: string[] | ||
import { getPackage, findPackages, PackageJsonWithRoot } from "./package" | ||
import { providers, WorkspaceProviderType } from "./workspace.providers" | ||
|
||
const defaultOptions = { | ||
cwd: process.cwd(), | ||
type: undefined as WorkspaceProviderType | undefined, | ||
includeRoot: false, | ||
} | ||
|
||
export type WorkspaceOptions = typeof defaultOptions | ||
|
||
export class Workspace { | ||
packages = new Map<string, PackageJsonWithRoot>() | ||
roots = new Map<string, string>() | ||
order: string[] | ||
|
||
private constructor( | ||
public root: string, | ||
packages: PackageJsonWithRoot[], | ||
public type: WorkspaceProviderType | ||
) { | ||
packages.forEach(p => { | ||
if (!p.name) p.name = p.root | ||
this.packages.set(p.name, p) | ||
this.roots.set(p.root, p.name) | ||
}) | ||
|
||
this.order = [] | ||
;[...this.packages.entries()].forEach(([name]) => { | ||
if (!this.order.includes(name)) { | ||
;[...this.getDepTree(name), name].forEach( | ||
n => this.order.includes(n) || this.order.push(n) | ||
) | ||
} | ||
}) | ||
} | ||
workspaces?: string[] | { packages?: string[] } | ||
} | ||
|
||
export type PackageJsonWithRoot = PackageJson & { | ||
root: string | ||
} | ||
static async getWorkspace(_options?: Partial<WorkspaceOptions>) { | ||
const options: WorkspaceOptions = { ...defaultOptions, ..._options } | ||
|
||
const types = options.type | ||
? [options.type] | ||
: (Object.keys(providers) as WorkspaceProviderType[]) | ||
|
||
for (const type of types) { | ||
const provider = providers[type] | ||
const info = await provider(options.cwd) | ||
if (info) { | ||
if (options.includeRoot) info.patterns.push(".") | ||
const packages = ( | ||
await findPackages(info.patterns, { cwd: info.root }) | ||
).map(p => getPackage(p)) as PackageJsonWithRoot[] | ||
return new Workspace(info.root, packages, type) | ||
} | ||
} | ||
} | ||
|
||
export enum WorkspaceType { | ||
single, | ||
lerna, | ||
yarn, | ||
pnpm, | ||
rush, | ||
recursive, | ||
} | ||
getPackageForRoot(root: string) { | ||
return this.roots.get(root) | ||
} | ||
|
||
export type Workspace = { | ||
type: WorkspaceType | ||
root: string | ||
packages: PackageJsonWithRoot[] | ||
} | ||
getDeps(pkgName: string) { | ||
return Object.keys(this.packages.get(pkgName)?.dependencies || {}).filter( | ||
dep => this.packages.has(dep) && dep !== pkgName | ||
) | ||
} | ||
|
||
type GlobOptions = { | ||
cwd?: string | ||
dot?: boolean | ||
absolute?: boolean | ||
filesOnly?: boolean | ||
directoriesOnly?: boolean | ||
flush?: boolean | ||
} | ||
_getDepTree(pkgName: string, seen: string[] = []) { | ||
if (seen.includes(pkgName)) return [] | ||
seen.push(pkgName) | ||
|
||
export async function glob(dirs: string[], options?: GlobOptions) { | ||
if (!options) options = {} | ||
options = { absolute: true, ...options } | ||
const ret = (await Promise.all(dirs.map(d => tinyGlob(d, options)))).flat() | ||
return options.directoriesOnly | ||
? ret.filter(f => | ||
fs | ||
.lstatSync(path.resolve(options?.cwd || process.cwd(), f)) | ||
.isDirectory() | ||
const ret: string[] = [] | ||
this.getDeps(pkgName).forEach(d => { | ||
;[...this._getDepTree(d, seen), d].forEach( | ||
dd => ret.includes(dd) || ret.push(dd) | ||
) | ||
: ret | ||
} | ||
|
||
export function findUp(name: string, cwd = process.cwd()): string | undefined { | ||
let up = path.resolve(cwd) | ||
do { | ||
cwd = up | ||
const p = path.resolve(cwd, name) | ||
if (fs.existsSync(p)) return cwd | ||
up = path.resolve(cwd, "../") | ||
} while (up !== cwd) | ||
} | ||
|
||
export function getPackage(root: string): PackageJsonWithRoot | undefined { | ||
const pkgPath = path.resolve(root, "package.json") | ||
return fs.existsSync(pkgPath) | ||
? { ...(require(pkgPath) as PackageJson), root } | ||
: undefined | ||
} | ||
|
||
export function findPackageUp( | ||
cwd = process.cwd() | ||
): PackageJsonWithRoot | undefined { | ||
const root = findUp("package.json", cwd) | ||
if (root) return getPackage(root) | ||
} | ||
|
||
async function getPackages( | ||
root: string, | ||
globs: string[], | ||
type: WorkspaceType | ||
): Promise<Workspace> { | ||
const packages = (await glob(globs, { cwd: root, directoriesOnly: true })) | ||
.map(p => getPackage(p)) | ||
.filter(p => p && p.name) as PackageJsonWithRoot[] | ||
|
||
// Sort packages in correct build order based on workspace dependencies | ||
const map = new Map<string, PackageJsonWithRoot>( | ||
packages.map(p => [p.name || "", p]) | ||
) | ||
const queue = packages.map(p => p.name) | ||
const order: string[] = [] | ||
while (queue.length) { | ||
const pname = queue.shift() as string | ||
const deps = Object.keys( | ||
(map.get(pname) as PackageJsonWithRoot).dependencies || {} | ||
).filter(d => map.has(d) && !order.includes(d)) | ||
if (deps.length) queue.push(...deps, pname) | ||
else if (!order.includes(pname)) order.push(pname) | ||
} | ||
return { | ||
type, | ||
root, | ||
packages: packages.sort( | ||
(p1, p2) => order.indexOf(p1.name || "") - order.indexOf(p2.name || "") | ||
), | ||
}) | ||
return ret | ||
} | ||
} | ||
|
||
export function getLernaWorkspace(cwd = process.cwd()) { | ||
const root = findUp("lerna.json", cwd) | ||
if (root) | ||
return getPackages( | ||
root, | ||
require(path.resolve(root, "lerna.json")).packages, | ||
WorkspaceType.lerna | ||
) | ||
} | ||
getDepTree(pkgName: string) { | ||
const ret = this._getDepTree(pkgName) | ||
const idx = ret.indexOf(pkgName) | ||
if (idx >= 0) ret.splice(idx, 1) | ||
return ret | ||
} | ||
|
||
export function getRushWorkspace(cwd = process.cwd()) { | ||
const root = findUp("rush.json", cwd) | ||
if (root) | ||
return getPackages( | ||
root, | ||
parse( | ||
fs.readFileSync(path.resolve(root, "rush.json")).toString() | ||
)?.projects.map((p: { projectFolder?: string }) => p.projectFolder), | ||
WorkspaceType.rush | ||
) | ||
} | ||
getPackages(filter?: string) { | ||
let ret = [...this.packages.values()] | ||
|
||
export function getYarnWorkspace(cwd = process.cwd()) { | ||
let root = findUp("package.json", cwd) | ||
while (root) { | ||
const pkg = getPackage(root) | ||
if (pkg?.workspaces) { | ||
if (Array.isArray(pkg.workspaces)) | ||
return getPackages(root, pkg.workspaces, WorkspaceType.yarn) | ||
if (Array.isArray(pkg.workspaces.packages)) | ||
return getPackages(root, pkg.workspaces.packages, WorkspaceType.yarn) | ||
if (filter) { | ||
const regex: RegExp = globrex(filter, { filepath: true }).regex | ||
ret = ret.filter( | ||
p => | ||
regex.test(p.name || "") || | ||
regex.test(path.relative(this.root, p.root).replace(/\\/gu, "/")) | ||
) | ||
} | ||
root = findUp("package.json", path.resolve(path.dirname(root), "..")) | ||
} | ||
} | ||
|
||
export function getPnpmWorkspace(cwd = process.cwd()) { | ||
const root = findUp("pnpm-workspace.yaml", cwd) | ||
if (root) { | ||
const y = yaml.parse( | ||
fs.readFileSync(path.resolve(root, "pnpm-workspace.yaml"), "utf8") | ||
return ret.sort( | ||
(a, b) => this.order.indexOf(a.name) - this.order.indexOf(b.name) | ||
) | ||
if (y.packages) return getPackages(root, y.packages, WorkspaceType.pnpm) | ||
} | ||
} | ||
|
||
export async function getRecursiveWorkspace(cwd = process.cwd()) { | ||
const dirs = ( | ||
await glob(["**/*/package.json"], { cwd, filesOnly: true }) | ||
).map(f => path.dirname(f)) | ||
if (dirs.length) return getPackages(cwd, dirs, WorkspaceType.recursive) | ||
} | ||
|
||
export async function getWorkspace( | ||
cwd = process.cwd(), | ||
recursive = false | ||
): Promise<Workspace | undefined> { | ||
if (!recursive) { | ||
const pkg = findPackageUp() | ||
return pkg | ||
? { root: pkg.root, type: WorkspaceType.single, packages: [pkg] } | ||
: undefined | ||
} | ||
const methods = [ | ||
getPnpmWorkspace, | ||
getYarnWorkspace, | ||
getLernaWorkspace, | ||
getRushWorkspace, | ||
getRecursiveWorkspace, | ||
] | ||
for (const m of methods) { | ||
const ret = await m(cwd) | ||
if (ret?.packages?.length) return ret | ||
} | ||
export async function getWorkspace(options?: Partial<WorkspaceOptions>) { | ||
return Workspace.getWorkspace(options) | ||
} |