|
| 1 | +/** |
| 2 | + * Narrows a `turbo prune --docker` output to what one package runs in production, so a |
| 3 | + * following `bun install --omit=dev` installs only its runtime dependencies. |
| 4 | + * |
| 5 | + * `--omit=dev` leaves out the root's devDependencies, but Bun still installs every workspace |
| 6 | + * member, each member's devDependencies, and their peers. A pruned image therefore carried the |
| 7 | + * build and test toolchain (drizzle-kit and its bundled esbuild, vitest, vite, tsx) that its |
| 8 | + * members declare for development, along with members such as `@sim/testing` that only tests |
| 9 | + * import. This keeps the members the target reaches through `dependencies`, |
| 10 | + * `optionalDependencies`, or a `tsconfig.json` `extends` (Bun reads each member's tsconfig when it |
| 11 | + * runs TypeScript source), drops every other member, and strips the devDependencies of the root |
| 12 | + * and the kept members. |
| 13 | + * |
| 14 | + * Usage: `bun scripts/prune-production-workspace.ts <package> <full-dir> [<json-dir>]`. The closure is |
| 15 | + * computed from `<full-dir>`, which holds the tsconfig files, and applied to every directory given. |
| 16 | + * The lockfile no longer matches the narrowed manifests, so install without `--frozen-lockfile`: |
| 17 | + * Bun keeps each locked resolution whose range still matches and only drops the entries the |
| 18 | + * narrowed workspace no longer reaches, so no version changes. |
| 19 | + */ |
| 20 | +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 21 | +import { dirname, join } from 'node:path' |
| 22 | + |
| 23 | +interface PackageManifest { |
| 24 | + name?: string |
| 25 | + workspaces?: string[] |
| 26 | + dependencies?: Record<string, string> |
| 27 | + optionalDependencies?: Record<string, string> |
| 28 | + devDependencies?: Record<string, string> |
| 29 | +} |
| 30 | + |
| 31 | +interface WorkspaceMember { |
| 32 | + /** Member directory relative to the workspace root, e.g. `packages/db`. */ |
| 33 | + dir: string |
| 34 | + manifest: PackageManifest |
| 35 | +} |
| 36 | + |
| 37 | +function readJson<T>(path: string): T { |
| 38 | + return JSON.parse(readFileSync(path, 'utf8')) as T |
| 39 | +} |
| 40 | + |
| 41 | +function listMembers(rootDir: string): Map<string, WorkspaceMember> { |
| 42 | + const root = readJson<PackageManifest>(join(rootDir, 'package.json')) |
| 43 | + const members = new Map<string, WorkspaceMember>() |
| 44 | + for (const pattern of root.workspaces ?? []) { |
| 45 | + for (const manifestPath of new Bun.Glob(`${pattern}/package.json`).scanSync(rootDir)) { |
| 46 | + const manifest = readJson<PackageManifest>(join(rootDir, manifestPath)) |
| 47 | + if (manifest.name) members.set(manifest.name, { dir: dirname(manifestPath), manifest }) |
| 48 | + } |
| 49 | + } |
| 50 | + return members |
| 51 | +} |
| 52 | + |
| 53 | +/** The package a module specifier names, e.g. `@sim/tsconfig` for `@sim/tsconfig/base.json`. */ |
| 54 | +function packageName(specifier: string): string { |
| 55 | + const segments = specifier.split('/') |
| 56 | + return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/') |
| 57 | +} |
| 58 | + |
| 59 | +function tsconfigParents(rootDir: string, member: WorkspaceMember): string[] { |
| 60 | + const tsconfigPath = join(rootDir, member.dir, 'tsconfig.json') |
| 61 | + if (!existsSync(tsconfigPath)) return [] |
| 62 | + const { extends: parents } = readJson<{ extends?: string | string[] }>(tsconfigPath) |
| 63 | + return [parents ?? []].flat().map(packageName) |
| 64 | +} |
| 65 | + |
| 66 | +function productionClosure( |
| 67 | + rootDir: string, |
| 68 | + members: Map<string, WorkspaceMember>, |
| 69 | + target: string |
| 70 | +): Set<string> { |
| 71 | + const kept = new Set<string>() |
| 72 | + const queue = [target] |
| 73 | + for (let name = queue.pop(); name; name = queue.pop()) { |
| 74 | + if (kept.has(name)) continue |
| 75 | + const member = members.get(name) |
| 76 | + if (!member) throw new Error(`${name} is not a member of the workspace in ${rootDir}`) |
| 77 | + kept.add(name) |
| 78 | + const { dependencies = {}, optionalDependencies = {} } = member.manifest |
| 79 | + const reached = [ |
| 80 | + ...Object.keys(dependencies), |
| 81 | + ...Object.keys(optionalDependencies), |
| 82 | + ...tsconfigParents(rootDir, member), |
| 83 | + ] |
| 84 | + queue.push(...reached.filter((next) => members.has(next))) |
| 85 | + } |
| 86 | + return kept |
| 87 | +} |
| 88 | + |
| 89 | +function writeJson(path: string, value: unknown) { |
| 90 | + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`) |
| 91 | +} |
| 92 | + |
| 93 | +/** Drops the members outside `kept`, from disk and from the root's workspace list. */ |
| 94 | +function narrow(rootDir: string, kept: Set<string>) { |
| 95 | + const dropped = new Set<string>() |
| 96 | + for (const [name, member] of listMembers(rootDir)) { |
| 97 | + const memberDir = join(rootDir, member.dir) |
| 98 | + if (!kept.has(name)) { |
| 99 | + rmSync(memberDir, { recursive: true, force: true }) |
| 100 | + dropped.add(member.dir) |
| 101 | + continue |
| 102 | + } |
| 103 | + const { devDependencies: _, ...manifest } = member.manifest |
| 104 | + writeJson(join(memberDir, 'package.json'), manifest) |
| 105 | + } |
| 106 | + // `turbo prune` lists each member's path, and Bun refuses a listed path that no longer exists. |
| 107 | + // The root's devDependencies go too: `--omit=dev` skips them, but while they stay in the |
| 108 | + // lockfile Bun still links them to satisfy optional peers (better-auth's on vitest). |
| 109 | + const rootPath = join(rootDir, 'package.json') |
| 110 | + const { devDependencies: _, ...root } = readJson<PackageManifest>(rootPath) |
| 111 | + writeJson(rootPath, { |
| 112 | + ...root, |
| 113 | + workspaces: (root.workspaces ?? []).filter((pattern) => !dropped.has(pattern)), |
| 114 | + }) |
| 115 | +} |
| 116 | + |
| 117 | +const [target, fullDir, ...otherDirs] = process.argv.slice(2) |
| 118 | +if (!target || !fullDir) { |
| 119 | + console.error( |
| 120 | + 'Usage: bun scripts/prune-production-workspace.ts <package> <full-dir> [<json-dir>]' |
| 121 | + ) |
| 122 | + process.exit(1) |
| 123 | +} |
| 124 | + |
| 125 | +const kept = productionClosure(fullDir, listMembers(fullDir), target) |
| 126 | +for (const dir of [fullDir, ...otherDirs]) narrow(dir, kept) |
| 127 | +console.log(`Production workspace for ${target}: ${[...kept].sort().join(', ')}`) |
0 commit comments