-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Cache package dependency collection results to avoid redundant filesystem walks #14672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| "@cloudflare/deploy-helpers": patch | ||
| --- | ||
|
|
||
| Cache package dependency collection results to avoid redundant filesystem walks | ||
|
|
||
| `collectPackageDependencies` now accepts an optional `cacheDir` parameter. When provided, collected results are persisted to disk and reused on subsequent calls as long as neither `package.json` nor the project's lockfile have been modified. This speeds up repeated deploys when dependencies haven't changed. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,11 @@ | ||
| import { access, readFile } from "node:fs/promises"; | ||
| import { createHash } from "node:crypto"; | ||
| import { access, mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import { | ||
| getInstalledPackageVersion, | ||
| parsePackageJSON, | ||
| } from "@cloudflare/workers-utils"; | ||
| import * as find from "empathic/find"; | ||
| import { logger } from "../../shared/context"; | ||
|
|
||
| /** | ||
|
|
@@ -26,6 +28,156 @@ export type PackageDependency = { | |
| */ | ||
| const MAX_PACKAGE_DEPENDENCIES = 200; | ||
|
|
||
| /** Filename used for the cached dependency results. */ | ||
| const CACHE_FILENAME = "package-dependencies.json"; | ||
|
|
||
| /** | ||
| * Lockfile names to look for when computing the cache key. | ||
| * Checked in order; the first match wins. | ||
| */ | ||
| const KNOWN_LOCKFILES = [ | ||
| "pnpm-lock.yaml", | ||
| "package-lock.json", | ||
| "yarn.lock", | ||
| "bun.lock", | ||
| "bun.lockb", | ||
| ] as const; | ||
|
|
||
| /** | ||
| * Shape of the on-disk cache file written to the caller-supplied cache directory. | ||
| */ | ||
| type PackageDependenciesCache = { | ||
| /** SHA-256 hex digest of the project's package.json content. */ | ||
| packageJsonHash: string; | ||
| /** SHA-256 hex digest of the lockfile content, or `null` if none was found. */ | ||
| lockfileHash: string | null; | ||
| /** Absolute path of the lockfile that was hashed, or `null` if none. */ | ||
| lockfilePath: string | null; | ||
| /** The collected dependency entries. */ | ||
| dependencies: PackageDependency[]; | ||
| }; | ||
|
|
||
| /** | ||
| * Computes the SHA-256 hex digest of a buffer or string. | ||
| * | ||
| * @param content - The content to hash | ||
| * @returns The hex-encoded SHA-256 digest | ||
| */ | ||
| function sha256(content: Buffer | string): string { | ||
| return createHash("sha256").update(content).digest("hex"); | ||
| } | ||
|
|
||
| /** | ||
| * Searches for the first known lockfile starting at `projectPath` and walking | ||
| * up parent directories toward the filesystem root. This ensures lockfiles at | ||
| * a monorepo/workspace root are detected even when the Worker lives in a | ||
| * sub-package. | ||
| * | ||
| * @param projectPath - The project directory to start searching from | ||
| * @returns An object with the lockfile's absolute `filePath` and its content `hash`, | ||
| * or `null` if no lockfile is found | ||
| */ | ||
| async function getLockfileInfo( | ||
| projectPath: string | ||
| ): Promise<{ filePath: string; hash: string } | null> { | ||
| for (const name of KNOWN_LOCKFILES) { | ||
| const filePath = find.file(name, { cwd: projectPath }); | ||
| if (filePath) { | ||
| const content = await readFile(filePath); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it worth wrapping this in a |
||
| return { filePath, hash: sha256(content) }; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
dario-piotrowicz marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Validates that a parsed JSON value conforms to the {@link PackageDependenciesCache} shape. | ||
| * Returns `false` if any field is missing or has the wrong type, causing the | ||
| * entire cache to be discarded. | ||
| * | ||
| * @param value - The parsed JSON value to validate | ||
| * @returns `true` if the value matches the expected cache structure, `false` otherwise | ||
| */ | ||
| function isValidDependenciesCache( | ||
| value: unknown | ||
| ): value is PackageDependenciesCache { | ||
| if (typeof value !== "object" || value === null || Array.isArray(value)) { | ||
| return false; | ||
| } | ||
|
|
||
| const obj = value as Record<string, unknown>; | ||
|
|
||
| if (typeof obj.packageJsonHash !== "string") { | ||
| return false; | ||
| } | ||
| if (obj.lockfileHash !== null && typeof obj.lockfileHash !== "string") { | ||
| return false; | ||
| } | ||
| if (obj.lockfilePath !== null && typeof obj.lockfilePath !== "string") { | ||
| return false; | ||
| } | ||
| if (!Array.isArray(obj.dependencies)) { | ||
| return false; | ||
| } | ||
|
|
||
| for (const dep of obj.dependencies) { | ||
| if (typeof dep !== "object" || dep === null || Array.isArray(dep)) { | ||
| return false; | ||
| } | ||
| const d = dep as Record<string, unknown>; | ||
| if ( | ||
| typeof d.name !== "string" || | ||
| typeof d.packageJsonVersion !== "string" || | ||
| typeof d.installedVersion !== "string" | ||
| ) { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Attempts to read a previously-written cache file. | ||
| * | ||
| * @param cachePath - Full path to the cache JSON file | ||
| * @returns The parsed cache contents, or `null` if the file is missing or malformed | ||
| */ | ||
| async function readDependenciesCache( | ||
| cachePath: string | ||
| ): Promise<PackageDependenciesCache | null> { | ||
| try { | ||
| const raw = await readFile(cachePath, "utf-8"); | ||
| const parsed: unknown = JSON.parse(raw); | ||
|
|
||
| if (!isValidDependenciesCache(parsed)) { | ||
| return null; | ||
| } | ||
|
|
||
| return parsed; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Writes the dependency cache to disk (best-effort; failures are silently ignored). | ||
| * | ||
| * @param cachePath - Full path to the cache JSON file | ||
| * @param data - The cache payload to persist | ||
| */ | ||
| async function writeDependenciesCache( | ||
| cachePath: string, | ||
| data: PackageDependenciesCache | ||
| ): Promise<void> { | ||
| try { | ||
| await mkdir(path.dirname(cachePath), { recursive: true }); | ||
| await writeFile(cachePath, JSON.stringify(data, null, 2)); | ||
| } catch { | ||
| // best-effort — a failure to write cache should never block a deploy | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Collects npm package dependency metadata from the project's package.json. | ||
| * | ||
|
|
@@ -38,12 +190,22 @@ const MAX_PACKAGE_DEPENDENCIES = 200; | |
| * | ||
| * The result is capped at {@link MAX_PACKAGE_DEPENDENCIES} entries. | ||
| * | ||
| * When `opts.cacheDir` is provided the results are cached to disk and reused | ||
| * on subsequent calls as long as neither `package.json` nor the project's | ||
| * lockfile have been modified (compared by content hash). | ||
| * | ||
| * @param projectPath - Path to the project directory (where package.json is located) | ||
| * @param opts - Optional settings | ||
| * @param opts.cacheDir - Directory to store the cache file. If omitted, caching is disabled. | ||
| * @returns An array of package dependency entries, or `undefined` if package.json | ||
| * cannot be read or no valid dependencies are found | ||
| */ | ||
| export async function collectPackageDependencies( | ||
| projectPath: string | ||
| projectPath: string, | ||
| opts?: { | ||
| /** Directory to store the cache file. If omitted, caching is disabled. */ | ||
| cacheDir?: string; | ||
| } | ||
| ): Promise<PackageDependency[] | undefined> { | ||
| const packageJsonPath = path.join(projectPath, "package.json"); | ||
|
|
||
|
|
@@ -54,8 +216,35 @@ export async function collectPackageDependencies( | |
| } | ||
|
|
||
| try { | ||
| const content = await readFile(packageJsonPath, "utf-8"); | ||
| const packageJson = parsePackageJSON(content, packageJsonPath); | ||
| const packageJsonContent = await readFile(packageJsonPath, "utf-8"); | ||
|
|
||
| const cachePath = opts?.cacheDir | ||
| ? path.join(opts.cacheDir, CACHE_FILENAME) | ||
| : null; | ||
|
|
||
| // Only compute hashes and resolve lockfile info when caching is enabled, | ||
| // to avoid unnecessary filesystem I/O (directory walks and large file | ||
| // reads) when cacheDir is not provided. | ||
| const packageJsonHash = cachePath ? sha256(packageJsonContent) : undefined; | ||
| const lockfileInfo = cachePath | ||
| ? await getLockfileInfo(projectPath) | ||
| : undefined; | ||
|
|
||
| if (cachePath && packageJsonHash !== undefined) { | ||
| // Attempt to return cached results when available | ||
| const cached = await readDependenciesCache(cachePath); | ||
| if ( | ||
| cached && | ||
| cached.packageJsonHash === packageJsonHash && | ||
| cached.lockfileHash === (lockfileInfo?.hash ?? null) && | ||
| cached.lockfilePath === (lockfileInfo?.filePath ?? null) | ||
| ) { | ||
| logger?.debug("Using cached package dependency results"); | ||
| return cached.dependencies.length > 0 ? cached.dependencies : undefined; | ||
| } | ||
|
dario-piotrowicz marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const packageJson = parsePackageJSON(packageJsonContent, packageJsonPath); | ||
|
|
||
| const allDependencies = { | ||
| ...packageJson.dependencies, | ||
|
|
@@ -96,9 +285,19 @@ export async function collectPackageDependencies( | |
| }); | ||
| } | ||
|
|
||
| // Persist the cache for future calls | ||
| if (cachePath && packageJsonHash !== undefined) { | ||
| await writeDependenciesCache(cachePath, { | ||
| packageJsonHash, | ||
| lockfileHash: lockfileInfo?.hash ?? null, | ||
| lockfilePath: lockfileInfo?.filePath ?? null, | ||
| dependencies: result, | ||
| }); | ||
| } | ||
|
|
||
| return result.length > 0 ? result : undefined; | ||
| } catch (error) { | ||
| logger.debug( | ||
| logger?.debug( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is |
||
| `Failed to collect package dependencies: ${error instanceof Error ? error.message : error}` | ||
| ); | ||
| return undefined; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this dependency really needed? Can we not achieve the same functionality as
empathic/findwith regular Node.js tools?