Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/cache-package-dependencies.md
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.
1 change: 1 addition & 0 deletions packages/deploy-helpers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@types/node": "catalog:default",
"concurrently": "^8.2.2",
"devtools-protocol": "^0.0.1182435",
"empathic": "^2.0.0",

Copy link
Copy Markdown
Member

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/find with regular Node.js tools?

"esbuild": "catalog:default",
"json-diff": "^1.0.6",
"ts-dedent": "^2.2.0",
Expand Down
4 changes: 3 additions & 1 deletion packages/deploy-helpers/src/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,9 @@ export default async function deploy(
cache: config.cache,
package_dependencies:
config.dependencies_instrumentation?.enabled !== false && projectRoot
? await collectPackageDependencies(projectRoot)
? await collectPackageDependencies(projectRoot, {
cacheDir: props.cacheDir,
})
: undefined,
};

Expand Down
209 changes: 204 additions & 5 deletions packages/deploy-helpers/src/deploy/helpers/package-dependencies.ts
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";

/**
Expand All @@ -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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth wrapping this in a try / catch so we can safely return null with a console warning instead?

return { filePath, hash: sha256(content) };
}
}
return null;
}
Comment thread
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.
*
Expand All @@ -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");

Expand All @@ -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;
}
Comment thread
dario-piotrowicz marked this conversation as resolved.
}

const packageJson = parsePackageJSON(packageJsonContent, packageJsonPath);

const allDependencies = {
...packageJson.dependencies,
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is logger now nullish? I assumed it would be a singleton, no?

`Failed to collect package dependencies: ${error instanceof Error ? error.message : error}`
);
return undefined;
Expand Down
4 changes: 3 additions & 1 deletion packages/deploy-helpers/src/deploy/versions-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ export default async function versionsUpload(
cache: config.cache, // cache is a versioned setting
package_dependencies:
config.dependencies_instrumentation?.enabled !== false && projectRoot
? await collectPackageDependencies(projectRoot)
? await collectPackageDependencies(projectRoot, {
cacheDir: props.cacheDir,
})
: undefined,
};

Expand Down
2 changes: 2 additions & 0 deletions packages/deploy-helpers/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export type SharedDeployVersionsProps = {
accountId: string | undefined;
/** Resolved from getMetricsUsageHeaders() / config.send_metrics. Controls whether usage metrics headers are sent with upload requests. */
sendMetrics: boolean;
/** Optional directory for caching intermediate results (e.g. package dependency collection). Callers set this to a project-local cache directory. */
cacheDir?: string;
/** Resolved from getFlag("RESOURCES_PROVISION"). Controls whether bindings are auto-provisioned before upload. */
resourcesProvision: boolean;
/** Controls whether provisioned resource IDs are written back to the config file. */
Expand Down
Loading
Loading